diff --git a/docs/FASTER_QUEUE_RECOVERY.md b/docs/FASTER_QUEUE_RECOVERY.md new file mode 100644 index 000000000..f1493dba8 --- /dev/null +++ b/docs/FASTER_QUEUE_RECOVERY.md @@ -0,0 +1,48 @@ +# Faster Queue Recovery + +When a queued target disappears while a course request is outstanding, the game can remove that target before its failed response arrives. Native failure handling then attempts the removal again. Since it did not remove the front entry this time, it can leave the next target waiting for the watchdog. + +Enable the opt-in recovery at startup: + +```toml +[control] +faster_queue_recovery = true +``` + +The feature also respects `control.queue_enabled`. Support is limited to the verified Windows x64 client 261 layout. Other platforms do not install these hooks; incompatible Windows layouts log an unavailable message and retain native behavior. + +## Behavior + +The adapter records the latest engagement attempt for up to eight fleets, using weak queue identities and full 64-bit target IDs. A failed, non-recall course response may request native planning only when: + +- The response belongs to the same queue and latest target/attempt, observed within 30 seconds. +- The queue was engaging on entry to the response and native processing cleared that flag. +- A different front target remains, and the failed target is absent from every inspected queue. +- The native retry decision was false. Ordinary retries are unchanged. + +The request record is consumed once. The native planner selects and validates the next target. The mod does not force ship state, clear engagement flags, change retry counts, or remove targets. If another fleet still queues the target, the native cross-fleet removal path remains responsible. + +Queue storage is bounded and validated. Unknown layouts/content are ineligible. Expired/replaced requests release their weak handles; native session cleanup clears all records. A native attempt returning skip/stop cancels only its own record, preserving newer reentrant attempts. + +There is no watchdog hook, frame scan, timer, background worker, or per-engagement logging. Queue inspection occurs in existing callbacks; all-queue inspection runs only for a potentially eligible failed response. Response matching is not a server-issued request ID, so delayed same-target responses remain an interoperability limitation. + +## Native integration + +One detour owns each method. Installation checks metadata, exact RVA, PE unwind extent, and a 24-byte prefix before installing any hook. Windows x64 client 261 targets: + +| Method | RVA | Native extent | +| --- | --- | --- | +| TryPlanPathAndEngageTarget | `0x1109f60` | 2340 bytes | +| ShouldRetryFailedSetCourse | `0x110d280` | 662 bytes | +| OnSetCourseResponseEventHandler | `0x110d070` | 514 bytes | +| StopWatchdogAndClearAllQueues | `0x110bcc0` | 493 bytes | + +The course event is a 24-byte value type, with fleet ID at 0, success/recall at 8/9 and boxed target at 16. Metadata field offsets include the boxed object header. The native retry handler is called synchronously inside the course handler; returning true selects its existing planner branch. + +**THIS WAS FIXED BY SCOPELY:** the older off-screen Kir'Shara combat-completion repair is obsolete. Remove that workaround and its `kirshara_queue_repair` setting; it is not a prerequisite for Faster Queue Recovery. This feature addresses only the separate unavailable-target/course-response race described above. + +## Validation + +Run `tests/run-action-queue.ps1` on Windows, or compile `tests/action_queue.cc` with a C++23 compiler and `-Imods/src`. Tests use the production policy and request store with fake weak handles. They cover one-shot recovery, reordered/still-present targets, cross-fleet rejection, stale attempts, replaced/collected queues, expiry, reentrant cancellation, bounded capacity and cleanup. They do not model game ABI or network scheduling. + +The prototype produced two observed handoffs with the next target attempted 1–2 ms later and successful responses within 388–532 ms. Those timings are observations, not a latency guarantee. The final adapter requires its own smoke test: normal queued combat; removal of the outstanding target; removal before the first successful course response; queue clear/rebuild; recall; and session restart. Group-wave behavior needs additional coverage when available. diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 5e0466f95..7b9c80211 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -116,6 +116,8 @@ queue_enabled = true # Retired off-screen queue completion repair: THIS WAS FIXED BY SCOPELY. # The former kirshara_queue_repair setting is no longer used. +# Schnellere Warteschlangen-Wiederaufnahme, wenn ein Ziel verschwindet (nur Windows-Client 261). +faster_queue_recovery = false # Auf true setzen, um die Hotkeys von Scopely zu verwenden use_scopely_hotkeys = false diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 9b1569dca..e186921b0 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -116,6 +116,8 @@ queue_enabled = true # Retired off-screen queue completion repair: THIS WAS FIXED BY SCOPELY. # The former kirshara_queue_repair setting is no longer used. +# Faster Queue Recovery: advance after an outstanding target disappears (Windows client 261 only). +faster_queue_recovery = false # If you prefer to use Scopely's hotkeys set this to true use_scopely_hotkeys = false diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index ab5463865..1bc5a68ea 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -116,6 +116,8 @@ queue_enabled = true # Retired off-screen queue completion repair: THIS WAS FIXED BY SCOPELY. # The former kirshara_queue_repair setting is no longer used. +# Reprise plus rapide de la file si une cible disparaît (client Windows 261 uniquement). +faster_queue_recovery = false # Si vous préférez utiliser les raccourcis de Scopely mettez ça sur activer use_scopely_hotkeys = false diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index 67157e6fe..51d409d22 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -116,6 +116,8 @@ queue_enabled = true # Retired off-screen queue completion repair: THIS WAS FIXED BY SCOPELY. # The former kirshara_queue_repair setting is no longer used. +# Sneller doorgaan met de wachtrij als een doel verdwijnt (alleen Windows-client 261). +faster_queue_recovery = false # Als je de Scopely sneltoetsen prefereerd over die van de mod, schakel dit dan aan use_scopely_hotkeys = false diff --git a/mods/src/config.cc b/mods/src/config.cc index 2e7806b8d..8296dec7f 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -1015,6 +1015,8 @@ void Config::Load() this->installPinnedShipSortHooks = get_config_or_default(config, parsed, "patches", "pinnedshiphooks", DCP::pinnedshiphooks, write_config); spdlog::debug(""); + this->faster_queue_recovery = get_config_or_default(config, parsed, "control", "faster_queue_recovery", + DCC::faster_queue_recovery, write_config); this->queue_enabled = get_config_or_default(config, parsed, "control", "queue_enabled", DCC::queue_enabled, write_config); this->hotkeys_enabled = diff --git a/mods/src/config.h b/mods/src/config.h index 3541d32f5..d290ab1cb 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -188,6 +188,7 @@ class Config final int select_timer; bool queue_enabled; + bool faster_queue_recovery; bool hotkeys_enabled; bool hotkeys_extended; bool use_scopely_hotkeys; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index b7b19ded7..14f1b46f4 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -33,6 +33,7 @@ namespace Control constexpr bool use_scopely_hotkeys = false; constexpr const char* keyboard_layout_mode = "physical"; constexpr bool queue_enabled = true; + constexpr bool faster_queue_recovery = false; constexpr auto select_timer = 500; } // namespace Control diff --git a/mods/src/patches/parts/action_queue.cc b/mods/src/patches/parts/action_queue.cc new file mode 100644 index 000000000..2ad439a3a --- /dev/null +++ b/mods/src/patches/parts/action_queue.cc @@ -0,0 +1,296 @@ +#include "action_queue.h" +#include + +// The callback ABI and native extents below have been verified only for client 261 on Windows x64. +#if defined(_WIN32) && defined(_M_X64) +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using action_queue::Clock; +using action_queue::QueueState; +struct WeakQueues { + using Object = Il2CppObject*; + using Handle = Il2CppGCHandle; + static Handle New(Object value) + { return il2cpp_gchandle_new_weakref(value, false); } + static Object Get(Handle handle) + { return il2cpp_gchandle_get_target(handle); } + static void Free(Handle handle) + { il2cpp_gchandle_free(handle); } +}; +action_queue::Requests requests; +std::mutex requestsMutex; +std::atomic_bool ready{false}; +Il2CppClass * queueClass{}, *actionClass{}, *int64Class{}; + +bool Enabled() +{ return ready.load() && Config::Get().faster_queue_recovery && Config::Get().queue_enabled; } +void ClearRequests() +{ + std::lock_guard lock(requestsMutex); + requests.Clear(); +} +template T Read(const void* object, std::size_t offset) +{ + T value{}; + if (object) + std::memcpy(&value, static_cast(object) + offset, sizeof(value)); + return value; +} +Il2CppClass* Resolve(const char* assembly, const char* ns, const char* name) +{ + auto* domain = il2cpp_domain_get(); + auto* loaded = domain ? il2cpp_domain_assembly_open(domain, assembly) : nullptr; + auto* image = loaded ? il2cpp_assembly_get_image(loaded) : nullptr; + return image ? il2cpp_class_from_name(image, ns, name) : nullptr; +} + +// Inspect actual List storage without invoking game properties or enumerators. +// Unknown storage/layout is ineligible, never equivalent to an empty queue or absent target. +QueueState Inspect(Il2CppObject* queue, std::int64_t target = 0) +{ + QueueState s; + if (!queue || il2cpp_object_get_class(queue) != queueClass) + return s; + s.fleet = Read(queue, 0x30); + s.attempt = Read(queue, 0x14); + s.engaging = Read(queue, 0x10); + auto* list = Read(queue, 0x28); + if (!list) + return s; + auto* cls = il2cpp_object_get_class(list); + auto* size = il2cpp_class_get_field_from_name(cls, "_size"); + auto* storage = il2cpp_class_get_field_from_name(cls, "_items"); + if (!size || !storage || !size->type || !storage->type || size->type->type != IL2CPP_TYPE_I4 + || storage->type->type != IL2CPP_TYPE_SZARRAY) + return s; + Il2CppArray* items{}; + il2cpp_field_get_value(list, size, &s.count); + il2cpp_field_get_value(list, storage, &items); + if (s.count < 0 || s.count > 128 || !items || il2cpp_array_length(items) < static_cast(s.count) + || il2cpp_class_get_element_class(il2cpp_object_get_class(reinterpret_cast(items))) != actionClass) + return s; + auto* array = reinterpret_cast(items); + const auto inspectCount = target ? s.count : (s.count > 0 ? 1 : 0); + for (int i = 0; i < inspectCount; ++i) { + auto* action = static_cast(array->vector[i]); + if (!action || il2cpp_object_get_class(action) != actionClass) + return s; + const auto id = Read(action, 0x10); + if (i == 0) + s.front = id; + if (target && id == target) + s.containsTarget = true; + } + s.valid = s.fleet != 0; + return s; +} +Il2CppArraySize* Queues(Il2CppObject* manager) +{ + auto* array = Read(manager, 0x48); + if (!array || il2cpp_array_length(array) > 64 + || il2cpp_class_get_element_class(il2cpp_object_get_class(reinterpret_cast(array))) != queueClass) + return nullptr; + return reinterpret_cast(array); +} +Il2CppObject* FindQueue(Il2CppObject* manager, std::int64_t fleet) +{ + auto* array = Queues(manager); + if (!array) + return nullptr; + for (unsigned i = 0; i < array->max_length; ++i) { + auto* queue = static_cast(array->vector[i]); + if (queue && il2cpp_object_get_class(queue) == queueClass && Read(queue, 0x30) == fleet) + return queue; + } + return nullptr; +} +bool AbsentFromAllQueues(Il2CppObject* manager, std::int64_t target) +{ + auto* array = Queues(manager); + if (!array) + return false; + for (unsigned i = 0; i < array->max_length; ++i) { + auto* queue = static_cast(array->vector[i]); + if (!queue) + continue; + const auto s = Inspect(queue, target); + if (!s.valid || s.containsTarget) + return false; + } + return true; +} +struct CourseContext { + std::int64_t fleet{}, target{}; + Il2CppObject* queue{}; // Borrowed only inside the synchronous native Course call. + bool outstandingAtEntry{}; +}; +thread_local CourseContext* currentCourse{}; + +int Engage(auto original, Il2CppObject* manager, Il2CppObject* player, Il2CppObject* queue) +{ + std::uint64_t serial{}; + if (Enabled()) { + try { + const auto s = Inspect(queue); + std::lock_guard lock(requestsMutex); + serial = requests.Remember(queue, s, Clock::now()); + } catch (...) { + ClearRequests(); + } + } else { + ClearRequests(); + } + const auto result = original(manager, player, queue); + // Result zero means native dispatch succeeded. Do not cancel a newer reentrant request. + if (serial && result != 0) { + std::lock_guard lock(requestsMutex); + requests.Cancel(serial); + } + return result; +} +bool Retry(auto original, Il2CppObject* manager, std::int64_t target, Il2CppObject* queue) +{ + const bool retry = original(manager, target, queue); + if (retry || !Enabled() || !currentCourse) + return retry; + try { + const auto& c = *currentCourse; + const auto s = Inspect(queue, target); + if (c.target != target + || !action_queue::CanAdvance(s, c.fleet, target, c.outstandingAtEntry, c.queue == queue, true)) + return retry; + // The native false branch processes this target across all fleets. Never suppress that work + // while any other queue still contains it or has contents we cannot verify. + if (!AbsentFromAllQueues(manager, target)) + return retry; + std::lock_guard lock(requestsMutex); + if (!requests.Consume(queue, s, target, Clock::now())) + return retry; + // The enclosing Course handler's true branch calls its normal planner for this same fleet. + // That planner retains all eligibility checks. No flags, targets or retry counters are changed here. + return true; + } catch (...) { + return retry; + } +} +struct CourseResponse { + std::int64_t fleet; + bool success, recall; + unsigned char padding[6]; + Il2CppObject* target; +}; +static_assert(sizeof(CourseResponse) == 24 && offsetof(CourseResponse, target) == 16); +void Course(auto original, Il2CppObject* manager, CourseResponse args) +{ + CourseContext context{args.fleet}; + if (Enabled() && !args.success && !args.recall) { + try { + if (args.target && il2cpp_object_get_class(args.target) == int64Class) { + std::memcpy(&context.target, il2cpp_object_unbox(args.target), sizeof(context.target)); + context.queue = FindQueue(manager, args.fleet); + context.outstandingAtEntry = context.queue && Read(context.queue, 0x10); + } + } catch (...) { + context = {}; + } + } + struct Scope { + CourseContext* previous{currentCourse}; + explicit Scope(CourseContext* value) + { currentCourse = value; } + ~Scope() + { currentCourse = previous; } + } scope(&context); + original(manager, args); +} +void ClearAll(auto original, Il2CppObject* manager) +{ + // Native session end/invalidation/quit share this seam; release weak handles while IL2CPP is alive. + ClearRequests(); + original(manager); +} +bool Field(Il2CppClass* cls, const char* name, std::ptrdiff_t offset, Il2CppTypeEnum type) +{ + auto* field = cls ? il2cpp_class_get_field_from_name(cls, name) : nullptr; + return field && field->offset == offset && field->type && field->type->type == type; +} +bool Method(void* method, std::uintptr_t rva, unsigned extent, const std::array& bytes) +{ + const auto base = reinterpret_cast(GetModuleHandleW(L"GameAssembly.dll")); + if (!base || reinterpret_cast(method) != base + rva) + return false; + DWORD64 image{}; + auto* entry = RtlLookupFunctionEntry(base + rva, &image, nullptr); + return entry && image == base && entry->BeginAddress == rva && entry->EndAddress - entry->BeginAddress == extent + && std::memcmp(method, bytes.data(), bytes.size()) == 0; +} +} // namespace + +void InstallActionQueueRecovery() +{ + if (!Config::Get().faster_queue_recovery) + return; + auto* cls = Resolve("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueManager"); + queueClass = Resolve("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueInstance"); + actionClass = Resolve("Assembly-CSharp", "Prime.ActionQueue", "QueueableAction"); + int64Class = Resolve("mscorlib", "System", "Int64"); + if (!cls || !queueClass || !actionClass || !int64Class) { + spdlog::warn("[FasterQueueRecovery] unavailable: native types not found"); + return; + } + IL2CppClassHelper manager(cls); + auto* engage = + manager.GetMethodSpecial("TryPlanPathAndEngageTarget", [](int n, const Il2CppType**) { return n == 2; }); + auto* retry = manager.GetMethodSpecial("ShouldRetryFailedSetCourse", [](int n, const Il2CppType** p) { + return n == 2 && p && p[0] && !p[0]->byref && p[0]->type == IL2CPP_TYPE_I8; + }); + auto* clear = + manager.GetMethodSpecial("StopWatchdogAndClearAllQueues", [](int n, const Il2CppType**) { return n == 0; }); + auto* info = manager.GetMethodInfoSpecial("OnSetCourseResponseEventHandler", [](int n, const Il2CppType** p) { + return n == 1 && p && p[0] && !p[0]->byref && p[0]->type == IL2CPP_TYPE_VALUETYPE; + }); + auto* course = info ? reinterpret_cast(info->methodPointer) : nullptr; + auto* event = info ? il2cpp_class_from_type(info->parameters[0]) : nullptr; + std::uint32_t alignment{}; + const bool valid = + event && il2cpp_class_value_size(event, &alignment) == sizeof(CourseResponse) + && Field(event, "k__BackingField", 0x10, IL2CPP_TYPE_I8) + && Field(event, "k__BackingField", 0x18, IL2CPP_TYPE_BOOLEAN) + && Field(event, "k__BackingField", 0x19, IL2CPP_TYPE_BOOLEAN) + && Field(event, "k__BackingField", 0x20, IL2CPP_TYPE_OBJECT) + && Field(cls, "_battleQueue", 0x48, IL2CPP_TYPE_SZARRAY) + && Field(queueClass, "IsEngaging", 0x10, IL2CPP_TYPE_BOOLEAN) + && Field(queueClass, "LastEngageAttemptTime", 0x14, IL2CPP_TYPE_R4) + && Field(queueClass, "k__BackingField", 0x30, IL2CPP_TYPE_I8) + && Field(queueClass, "_actionQueue", 0x28, IL2CPP_TYPE_GENERICINST) + && Field(actionClass, "k__BackingField", 0x10, IL2CPP_TYPE_I8) + && Method(engage, 0x1109f60, 2340, {0x4c, 0x89, 0x44, 0x24, 0x18, 0x48, 0x89, 0x54, 0x24, 0x10, 0x48, 0x89, + 0x4c, 0x24, 0x08, 0x53, 0x56, 0x57, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56}) + && Method(retry, 0x110d280, 662, {0x48, 0x89, 0x5c, 0x24, 0x10, 0x48, 0x89, 0x6c, 0x24, 0x18, 0x57, 0x48, + 0x83, 0xec, 0x20, 0x80, 0x3d, 0x7a, 0x04, 0xb1, 0x04, 0x00, 0x49, 0x8b}) + && Method(course, 0x110d070, 514, {0x48, 0x89, 0x5c, 0x24, 0x18, 0x57, 0x48, 0x83, 0xec, 0x20, 0x80, 0x3d, + 0x8e, 0x06, 0xb1, 0x04, 0x00, 0x48, 0x8b, 0xfa, 0x48, 0x8b, 0xd9, 0x75}) + && Method(clear, 0x110bcc0, 493, {0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x70, 0x48, 0x8b, + 0xd9, 0x80, 0x3d, 0x33, 0x1a, 0xb1, 0x04, 0x00, 0x75, 0x5c, 0x48, 0x8d}); + if (!valid) { + spdlog::warn("[FasterQueueRecovery] unavailable: requires verified Windows x64 client 261 layout"); + return; + } + const bool a = SPUD_STATIC_DETOUR(engage, Engage) != nullptr; + const bool b = SPUD_STATIC_DETOUR(retry, Retry) != nullptr; + const bool c = SPUD_STATIC_DETOUR(course, Course) != nullptr; + const bool d = SPUD_STATIC_DETOUR(clear, ClearAll) != nullptr; + ready.store(a && b && c && d); + spdlog::info("[FasterQueueRecovery] ready={}", ready.load()); +} +#else +void InstallActionQueueRecovery() {} +#endif diff --git a/mods/src/patches/parts/action_queue.h b/mods/src/patches/parts/action_queue.h new file mode 100644 index 000000000..3e0f1e06e --- /dev/null +++ b/mods/src/patches/parts/action_queue.h @@ -0,0 +1,104 @@ +#pragma once + +#include +#include +#include + +namespace action_queue +{ +using Clock = std::chrono::steady_clock; + +struct QueueState { + bool valid{}; + std::int64_t fleet{}, front{}; + float attempt{}; + int count{}; + bool engaging{}, containsTarget{}; +}; + +// The pending-target field describes the last successful response, not necessarily the outstanding request. +inline bool CanAdvance(const QueueState& state, std::int64_t failedFleet, std::int64_t failedTarget, + bool outstandingAtEntry, bool sameQueue, bool absentFromAllQueues) +{ + return state.valid && sameQueue && outstandingAtEntry && failedTarget != 0 && state.fleet == failedFleet + && state.count > 0 && state.front != failedTarget && !state.engaging && !state.containsTarget + && absentFromAllQueues; +} + +// Handles provides weak identity without keeping native queues alive. Calls are serialized by the adapter. +template class Requests +{ +public: + using Object = typename Handles::Object; + using Handle = typename Handles::Handle; + + std::uint64_t Remember(Object queue, const QueueState& state, Clock::time_point now) + { + auto* slot = &records_.front(); + for (auto& record : records_) { + if (record.handle && (now - record.started >= lifetime || !Handles::Get(record.handle))) + Release(record); + if (record.fleet == state.fleet) { + slot = &record; + break; + } + if (record.started < slot->started) + slot = &record; + } + Release(*slot); + const auto serial = ++serial_; + if (state.valid && state.count > 0 && state.front != 0) + *slot = {Handles::New(queue), state.fleet, state.front, state.attempt, now, serial}; + return serial; + } + + // A native attempt that did not dispatch must not authorize a later failure response. + void Cancel(std::uint64_t serial) + { + for (auto& record : records_) + if (record.serial == serial) + Release(record); + } + + bool Consume(Object queue, const QueueState& state, std::int64_t target, Clock::time_point now) + { + for (auto& record : records_) { + if (record.fleet != state.fleet) + continue; + if (!record.handle || now - record.started >= lifetime || !Handles::Get(record.handle)) { + Release(record); + return false; + } + if (Handles::Get(record.handle) != queue || record.target != target || record.attempt != state.attempt) + return false; + Release(record); + return true; + } + return false; + } + + void Clear() + { + for (auto& record : records_) + Release(record); + } + +private: + struct Record { + Handle handle{}; + std::int64_t fleet{}, target{}; + float attempt{}; + Clock::time_point started{}; + std::uint64_t serial{}; + }; + static void Release(Record& record) + { + if (record.handle) + Handles::Free(record.handle); + record = {}; + } + static constexpr auto lifetime = std::chrono::seconds(30); + std::array records_{}; + std::uint64_t serial_{}; +}; +} // namespace action_queue diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index e002f729b..cb61937fd 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -58,6 +58,7 @@ void InstallGameErrorProbe(); #endif void InstallNativeSettings(); void InstallGalaxyLabels(); +void InstallActionQueueRecovery(); __int64 il2cpp_init_hook(auto original, const char* domain_name) { @@ -196,6 +197,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) InstallDevConsole(); InstallGameErrorProbe(); #endif + InstallActionQueueRecovery(); spdlog::info(""); diff --git a/tests/action_queue.cc b/tests/action_queue.cc new file mode 100644 index 000000000..39028fb27 --- /dev/null +++ b/tests/action_queue.cc @@ -0,0 +1,120 @@ +#include "patches/parts/action_queue.h" +#include +#include +#include + +namespace +{ +struct Handles { + using Object = int; + using Handle = unsigned; + static inline unsigned serial{}; + static inline std::map live; + static Handle New(Object object) + { + live[++serial] = object; + return serial; + } + static Object Get(Handle handle) + { + auto it = live.find(handle); + return it == live.end() ? 0 : it->second; + } + static void Free(Handle handle) + { assert(live.erase(handle) == 1); } + static void Collect(Object object) + { + for (auto& [_, value] : live) + if (value == object) + value = 0; + } +}; +using Requests = action_queue::Requests; +using action_queue::CanAdvance; +using action_queue::QueueState; +using namespace std::chrono_literals; +const auto now = action_queue::Clock::time_point{} + 100s; +QueueState dispatch{true, 10, 100, 2.5f, 2, false, false}; +QueueState remaining{true, 10, 200, 2.5f, 1, false, false}; +} // namespace +int main() +{ + Requests requests; + // Removed target: exactly one immediate handoff. Pending-success state is intentionally not an input. + requests.Remember(1, dispatch, now); + assert(CanAdvance(remaining, 10, 100, true, true, true)); + assert(requests.Consume(1, remaining, 100, now + 1s)); + assert(!requests.Consume(1, remaining, 100, now + 2s)); + assert(Handles::live.empty()); + + // Native retry remains responsible while the target is still queued, even after reordering. + auto reordered = remaining; + reordered.containsTarget = true; + assert(!CanAdvance(reordered, 10, 100, true, true, true)); + assert(!CanAdvance(dispatch, 10, 100, true, true, true)); + // Cross-fleet target cleanup must retain native false-branch behavior without consuming the request. + requests.Remember(1, dispatch, now); + assert(!CanAdvance(remaining, 10, 100, true, true, false)); + assert(requests.Consume(1, remaining, 100, now + 1s)); + // Invalid identity, missing/empty queue, ongoing engagement and absent outstanding request fail closed. + assert(!CanAdvance(remaining, 11, 100, true, true, true)); + assert(!CanAdvance(remaining, 10, 100, false, true, true)); + assert(!CanAdvance(remaining, 10, 100, true, false, true)); + assert(!CanAdvance(remaining, 10, 0, true, true, true)); + auto invalid = remaining; + invalid.valid = false; + assert(!CanAdvance(invalid, 10, 100, true, true, true)); + auto empty = remaining; + empty.count = 0; + assert(!CanAdvance(empty, 10, 100, true, true, true)); + auto active = remaining; + active.engaging = true; + assert(!CanAdvance(active, 10, 100, true, true, true)); + + // Latest target/attempt supersedes old failures; pointer reuse after collection cannot resurrect identity. + requests.Remember(1, dispatch, now); + assert(!requests.Consume(2, remaining, 100, now)); + auto newer = dispatch; + newer.front = 300; + newer.attempt = 3; + requests.Remember(1, newer, now + 1s); + assert(!requests.Consume(1, remaining, 100, now + 2s)); + requests.Remember(1, dispatch, now); + auto differentAttempt = remaining; + differentAttempt.attempt = 9; + assert(!requests.Consume(1, differentAttempt, 100, now)); + Handles::Collect(1); + assert(!requests.Consume(1, remaining, 100, now)); + assert(Handles::live.empty()); + + // Canceling an older reentrant call cannot cancel the newer dispatch. + auto old = requests.Remember(1, dispatch, now); + requests.Remember(1, dispatch, now + 1s); + requests.Cancel(old); + assert(requests.Consume(1, remaining, 100, now + 2s)); + auto failed = requests.Remember(1, dispatch, now); + requests.Cancel(failed); + assert(!requests.Consume(1, remaining, 100, now)); + + requests.Remember(1, dispatch, now); + assert(!requests.Consume(1, remaining, 100, now + 30s)); + assert(Handles::live.empty()); + // Bounded capacity, independent fleets and session cleanup release all handles. + for (int i = 1; i <= 9; ++i) { + auto s = dispatch; + s.fleet = i; + requests.Remember(i, s, now + std::chrono::seconds(i)); + assert(Handles::live.size() <= 8); + } + auto first = remaining; + first.fleet = 1; + assert(!requests.Consume(1, first, 100, now + 10s)); + auto last = remaining; + last.fleet = 9; + assert(requests.Consume(9, last, 100, now + 10s)); + requests.Clear(); + requests.Clear(); + assert(Handles::live.empty()); + assert(!requests.Consume(2, remaining, 100, now)); + std::cout << "Faster Queue Recovery policy and request-lifecycle tests passed\n"; +} diff --git a/tests/run-action-queue.ps1 b/tests/run-action-queue.ps1 new file mode 100644 index 000000000..5b80eb822 --- /dev/null +++ b/tests/run-action-queue.ps1 @@ -0,0 +1,12 @@ +# Standalone policy tests: no game, IL2CPP, or mod DLL required. +[CmdletBinding()] +param() +$ErrorActionPreference = 'Stop' +Push-Location (Split-Path -Parent $PSScriptRoot) +try { + New-Item -ItemType Directory -Path build -Force | Out-Null + & clang++ -std=c++23 -Imods/src tests/action_queue.cc -o build/action_queue_tests.exe + if ($LASTEXITCODE -ne 0) { throw 'Action queue test compilation failed.' } + & ./build/action_queue_tests.exe + if ($LASTEXITCODE -ne 0) { throw 'Action queue regression failed.' } +} finally { Pop-Location }