From d6c10b7d0d0988d4438c488cfa9354524d820106 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 21 Sep 2026 22:28:55 -0500 Subject: [PATCH 1/5] Guard existing IL2CPP class lookup when metadata is unavailable --- .github/workflows/ci.yaml | 12 ++++++ mods/src/il2cpp/il2cpp_helper.h | 6 +-- tests/il2cpp_class_lookup.cc | 71 +++++++++++++++++++++++++++++++++ xmake.lua | 11 +++++ 4 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 tests/il2cpp_class_lookup.cc diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 61cf42826..025c4356b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -189,6 +189,13 @@ jobs: shell: pwsh run: sccache --zero-stats + - name: Test IL2CPP class lookup + run: | + xmake build -y il2cpp-class-lookup-tests + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + xmake run il2cpp-class-lookup-tests + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Build run: xmake build -y stfc-community-mod @@ -469,6 +476,11 @@ jobs: shell: bash run: sccache --zero-stats + - name: Test IL2CPP class lookup + run: | + xmake build -y il2cpp-class-lookup-tests + xmake run il2cpp-class-lookup-tests + - name: Build # -D keeps the existing XMake compiler-cache diagnostics too. run: xmake -y -D diff --git a/mods/src/il2cpp/il2cpp_helper.h b/mods/src/il2cpp/il2cpp_helper.h index 3d4ff6e3f..19708b2cd 100644 --- a/mods/src/il2cpp/il2cpp_helper.h +++ b/mods/src/il2cpp/il2cpp_helper.h @@ -337,10 +337,10 @@ class IL2CppClassHelper inline IL2CppClassHelper il2cpp_get_class_helper_impl(const char* assembly, const char* namespacez, const char* name) { auto domain = il2cpp_domain_get(); - auto assemblyT = il2cpp_domain_assembly_open(domain, assembly); - auto image = il2cpp_assembly_get_image(assemblyT); + auto assemblyT = domain ? il2cpp_domain_assembly_open(domain, assembly) : nullptr; + auto image = assemblyT ? il2cpp_assembly_get_image(assemblyT) : nullptr; - auto cls = il2cpp_class_from_name(image, namespacez, name); + auto cls = image ? il2cpp_class_from_name(image, namespacez, name) : nullptr; return IL2CppClassHelper{cls}; } diff --git a/tests/il2cpp_class_lookup.cc b/tests/il2cpp_class_lookup.cc new file mode 100644 index 000000000..0d471ea00 --- /dev/null +++ b/tests/il2cpp_class_lookup.cc @@ -0,0 +1,71 @@ +// Production helpers with a controlled IL2CPP boundary, on Windows and macOS. +#include +#if _WIN32 +#undef IL2CPP_IMPORT +#define IL2CPP_IMPORT +#endif +#include "il2cpp/il2cpp_helper.h" +#include +#include +#include + +namespace +{ +Il2CppClass klass; +Il2CppObject object{}; +MethodInfo method{}; +int stage = 3; +} // namespace +#if _WIN32 +#define API(ret, name, params) extern "C" ret name params +#define END_API +#else +#define API(ret, name, params) name##_t name = +[] params->ret +#define END_API ; +#endif +API(Il2CppDomain*, il2cpp_domain_get, ()) +{ return stage >= 0 ? reinterpret_cast(&object) : nullptr; } +END_API +API(const Il2CppAssembly*, il2cpp_domain_assembly_open, (Il2CppDomain * domain, const char*)) +{ + if (!domain) + std::abort(); + return stage >= 1 ? reinterpret_cast(&object) : nullptr; +} +END_API +API(const Il2CppImage*, il2cpp_assembly_get_image, (const Il2CppAssembly* assembly)) +{ + if (!assembly) + std::abort(); + return stage >= 2 ? reinterpret_cast(&object) : nullptr; +} +END_API +API(Il2CppClass*, il2cpp_class_from_name, (const Il2CppImage* image, const char*, const char*)) +{ + if (!image) + std::abort(); + return stage >= 3 ? &klass : nullptr; +} +END_API +API(const MethodInfo*, il2cpp_class_get_method_from_name, (Il2CppClass * cls, const char*, int)) +{ + if (!cls) + std::abort(); + return &method; +} +END_API +void Require(bool condition) +{ + if (!condition) + throw std::runtime_error("class lookup regression"); +} +int main() +{ + for (stage = -1; stage < 3; ++stage) + Require(!il2cpp_get_class_helper("Assembly", "Namespace", "Class").get_cls()); + Require(il2cpp_get_class_helper("Assembly", "Namespace", "Class").get_cls() == &klass); + Require(!IL2CppClassHelper(nullptr).GetMethodInfo("Method", 0)); + Require(IL2CppClassHelper(&klass).GetMethodInfo("Method", 0) == &method); + + std::cout << "IL2CPP class lookup regressions passed\n"; +} diff --git a/xmake.lua b/xmake.lua index 9b25acbfb..cfbefafff 100644 --- a/xmake.lua +++ b/xmake.lua @@ -26,3 +26,14 @@ add_rules("mode.releasedbg") includes("xmake/rules/protobuf_sccache.lua") includes("xmake/rules/cxx_sccache.lua") includes("mods") + +target("il2cpp-class-lookup-tests") + set_kind("binary") + set_default(false) + add_files("tests/il2cpp_class_lookup.cc") + add_includedirs("mods/src") + add_packages("libil2cpp", "eastl") + set_exceptions("cxx") + if is_plat("windows") then + add_linkdirs("mods/src/il2cpp") + end From 46f144dd44aeabe8a25bc2538f9d0883cde51807 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Wed, 23 Sep 2026 21:23:34 -0500 Subject: [PATCH 2/5] Recover stalled queues through guarded native planner paths --- docs/FASTER_QUEUE_RECOVERY.md | 43 +++ docs/THIN_QUEUE_PROTECTION.md | 48 +++ example_community_patch_settings_de.toml | 7 + example_community_patch_settings_en.toml | 7 + example_community_patch_settings_fr.toml | 7 + example_community_patch_settings_nl.toml | 7 + mods/src/config.cc | 5 + mods/src/config.h | 2 + mods/src/defaultconfig.h | 2 + mods/src/il2cpp/method_contract.h | 44 +++ mods/src/patches/action_queue_guard_policy.h | 126 ++++++++ mods/src/patches/parts/action_queue.cc | 283 ++++++++++++++++++ mods/src/patches/parts/action_queue.h | 104 +++++++ .../patches/parts/thin_queue_protection.cc | 268 +++++++++++++++++ mods/src/patches/patches.cc | 5 + tests/action_queue.cc | 120 ++++++++ tests/run-action-queue.ps1 | 16 + tests/thin_queue_protection.cc | 230 ++++++++++++++ 18 files changed, 1324 insertions(+) create mode 100644 docs/FASTER_QUEUE_RECOVERY.md create mode 100644 docs/THIN_QUEUE_PROTECTION.md create mode 100644 mods/src/il2cpp/method_contract.h create mode 100644 mods/src/patches/action_queue_guard_policy.h create mode 100644 mods/src/patches/parts/action_queue.cc create mode 100644 mods/src/patches/parts/action_queue.h create mode 100644 mods/src/patches/parts/thin_queue_protection.cc create mode 100644 tests/action_queue.cc create mode 100644 tests/run-action-queue.ps1 create mode 100644 tests/thin_queue_protection.cc diff --git a/docs/FASTER_QUEUE_RECOVERY.md b/docs/FASTER_QUEUE_RECOVERY.md new file mode 100644 index 000000000..f21e3b8ca --- /dev/null +++ b/docs/FASTER_QUEUE_RECOVERY.md @@ -0,0 +1,43 @@ +# 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`. Hooks are supported on Windows x64 when the required method signatures and queue layout are compatible. 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. Methods are resolved through their complete managed signatures. Installation validates the event and queue field layouts and the engagement result's integer representation. SPUD builds the trampolines using its existing instruction decoder. Addresses and relocation-dependent instruction bytes are not pinned to a client release. + +The hooked methods are `TryPlanPathAndEngageTarget`, `ShouldRetryFailedSetCourse`, `OnSetCourseResponseEventHandler` and `StopWatchdogAndClearAllQueues`. These checks establish binding compatibility; changes to the native retry/planner behavior still require update review and runtime testing. + +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/docs/THIN_QUEUE_PROTECTION.md b/docs/THIN_QUEUE_PROTECTION.md new file mode 100644 index 000000000..ff2807792 --- /dev/null +++ b/docs/THIN_QUEUE_PROTECTION.md @@ -0,0 +1,48 @@ +# Thin Queue Protection + +Restores the active guard from `v2.1.0-guffa.10` alongside Faster Queue Recovery. +This is separate from the retired Kir'shara combat-completion repair. + +```toml +[control] +thin_queue_protection = true +``` + +Enabled by default on Windows x64. Also respects `control.queue_enabled`. The old +`advanced.queue.thin_queue_protection` value is used when the new control key is +absent, preserving an explicit opt-out. Hook installation requires a restart; +enabling a setting later does not install missing hooks. + +The native planner (`DoPlanPathAndEngageTarget`) and watchdog (`HandleStall`) run +first. If they removed an exact prefix of targets, left a nonempty unchanged +suffix, and left the same fleet idle without an active engagement, the guard +rechecks that state and asks the native `TryPlanPathAndEngageTarget` to resume. +Pending/last-target latches must be absent or refer only to removed targets. +Reordering, replacement, truncation, or a latch naming a surviving target rejects +recovery. An unchanged queue is never enough evidence to retry. + +On disposal, a fleet must be explicitly destroyed with removal reason `Destroyed` +(1). If native handling leaves that target at an inactive queue head, the guard +rechecks it and calls native `ProcessQueue(target, false)`. It does not force +immediate selection, remove arbitrary targets, or edit queue/engagement state. + +Faster Queue Recovery still owns the failed-course-response path. The restored +guard uses three different detours and calls the existing native engage entry, +so Faster Queue Recovery can observe those requests normally. The nested planner +and watchdog paths cannot replay a successfully engaged target because the outer +guard sees the resulting active engagement. Queue identity is checked again after +native calls. All three guard hooks must install before any guard action is enabled. + +Methods, field offsets/types and enum return representations are checked before +installation; incompatible layouts keep native behavior. This port currently +installs only on Windows x64. macOS runtime/ABI validation is not claimed. + +Successful actions log `[ThinQueueProtection] resume` or +`[ThinQueueProtection] process-destroyed-head`; startup logs `ready=true` or an +unavailable warning. There is no per-frame scan or background polling. + +Run `tests/run-action-queue.ps1` for both recovery-policy suites. The restored +release tests cover destroyed-head filtering, prefix removal, multiple removed +targets, latch safety, queue replacement/reordering and postcondition changes. +They do not establish game ABI or live wave behavior. Runtime verification still +requires a new build to be deployed and a wave test after restart. diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index faa367211..1e6424ae7 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -94,6 +94,13 @@ hotkeys_extended = true # Soll die Warteschlange standardmäßig aktiviert sein, wenn das Kir'Shara-Artefakt vorhanden ist? queue_enabled = true +# Schnellere Warteschlangen-Wiederaufnahme, wenn ein Ziel verschwindet (nur Windows x64). +faster_queue_recovery = false + +# Thin Queue Protection: nach nativer Bereinigung fortsetzen; zerstörte Ziele am Anfang entfernen. +# Nur Windows x64. Standardmäßig aktiv; unabhängig von Faster Queue Recovery. +thin_queue_protection = true + # 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 eec640425..5d6ec0f21 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -94,6 +94,13 @@ hotkeys_extended = true # If you have the Kir'Shara artifact, should the queue be enabled by default? queue_enabled = true +# Faster Queue Recovery: advance after an outstanding target disappears (Windows x64 only). +faster_queue_recovery = false + +# Thin Queue Protection: resume after native pruning; clean up confirmed destroyed heads. +# Windows x64 only. Enabled by default; independent of Faster Queue Recovery. +thin_queue_protection = true + # 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 f6f9541cd..3c73b537f 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -94,6 +94,13 @@ hotkeys_extended = true # Si vous avez l’artefact Kir’Shara, la file d’attente doit-elle être activée par défaut ? queue_enabled = true +# Reprise plus rapide de la file si une cible disparaît (Windows x64 uniquement). +faster_queue_recovery = false + +# Thin Queue Protection : reprendre après le nettoyage natif des cibles en tête de file. +# Windows x64 uniquement. Activé par défaut ; indépendant de Faster Queue Recovery. +thin_queue_protection = true + # 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 e3980b218..2065fe693 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -94,6 +94,13 @@ hotkeys_extended = true # Als je de Kir'Shara artefect hebt, moet de wachtrij standaard ingeschakeld zijn? queue_enabled = true +# Sneller doorgaan met de wachtrij als een doel verdwijnt (alleen Windows x64). +faster_queue_recovery = false + +# Thin Queue Protection: hervat na native opruiming; verwijder bevestigde vernietigde doelen vooraan. +# Alleen Windows x64. Standaard aan; onafhankelijk van Faster Queue Recovery. +thin_queue_protection = true + # 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 2efae99ae..6770d2303 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -929,6 +929,11 @@ 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); + // Preserve an explicit 2.1.0 setting; the current control key takes precedence. + this->thin_queue_protection = get_config_or_default(config, parsed, "control", "thin_queue_protection", + config["advanced"]["queue"]["thin_queue_protection"].value_or(DCC::thin_queue_protection), 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 37e73e307..8f8cb1b44 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -182,6 +182,8 @@ class Config final int select_timer; bool queue_enabled; + bool faster_queue_recovery; + bool thin_queue_protection; bool hotkeys_enabled; bool hotkeys_extended; bool use_scopely_hotkeys; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index 222e87d0d..2ad96beb3 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -26,6 +26,8 @@ namespace Control constexpr bool hotkeys_extended = true; constexpr bool use_scopely_hotkeys = false; constexpr bool queue_enabled = true; + constexpr bool faster_queue_recovery = false; + constexpr bool thin_queue_protection = true; constexpr auto select_timer = 500; } // namespace Control diff --git a/mods/src/il2cpp/method_contract.h b/mods/src/il2cpp/method_contract.h new file mode 100644 index 000000000..d7d1ccdc5 --- /dev/null +++ b/mods/src/il2cpp/method_contract.h @@ -0,0 +1,44 @@ +#pragma once + +#include "il2cpp-functions.h" +#include +#include +#include + +namespace method_contract +{ +inline bool Type(const Il2CppType* type, const char* name) +{ + if (!type || type->byref) return false; + auto* actual = il2cpp_type_get_name(type); + const bool matches = actual && std::strcmp(actual, name) == 0; + il2cpp_free(actual); + return matches; +} + +// Resolve the entire managed signature, including static/instance dispatch. +// A renamed, ambiguous or generic method is not a compatible callback. +inline const MethodInfo* Resolve(Il2CppClass* cls, const char* name, bool is_static, + const char* result, std::initializer_list parameters) +{ + if (!cls) return nullptr; + const MethodInfo* found = nullptr; + void* iterator = nullptr; + while (auto* method = il2cpp_class_get_methods(cls, &iterator)) { + if (std::strcmp(method->name, name) != 0 || !method->methodPointer || method->is_generic || method->is_inflated + || bool(method->flags & METHOD_ATTRIBUTE_STATIC) != is_static + || method->parameters_count != parameters.size() || !Type(method->return_type, result)) continue; + bool matches = true; + unsigned i = 0; + for (auto* parameter : parameters) + matches = Type(method->parameters[i++], parameter) && matches; + if (!matches) continue; + if (found) return nullptr; + found = method; + } + return found; +} + +inline void* Pointer(const MethodInfo* method) +{ return method ? reinterpret_cast(method->methodPointer) : nullptr; } +} // namespace method_contract diff --git a/mods/src/patches/action_queue_guard_policy.h b/mods/src/patches/action_queue_guard_policy.h new file mode 100644 index 000000000..418746235 --- /dev/null +++ b/mods/src/patches/action_queue_guard_policy.h @@ -0,0 +1,126 @@ +#pragma once + +#include +#include +#include + +namespace action_queue_guard +{ +constexpr int kEngageResultSuccess = 0; +constexpr int kEngageResultSkipTarget = 1; +constexpr int kEngageResultStop = 2; + +constexpr std::string_view EngageResultName(int result) +{ + switch (result) { + case kEngageResultSuccess: + return "success"; + case kEngageResultSkipTarget: + return "skip-target"; + case kEngageResultStop: + return "stop"; + default: + return result < 0 ? "not-attempted" : "unknown"; + } +} + +struct QueueState { + bool present = false; + std::int64_t player_fleet_id = 0; + int count = -1; + std::int64_t head_target_id = 0; + std::array target_ids{}; + int captured_target_count = 0; + bool targets_truncated = false; + bool is_engaging = false; + std::int64_t last_engaged_target_id = 0; + std::int64_t pending_target_id = 0; +}; + +constexpr bool ShouldInstall(bool protection_enabled, bool diagnostics_enabled) +{ return protection_enabled || diagnostics_enabled; } + +constexpr bool ShouldProcessDestroyedHead(bool enabled, bool target_destroyed, std::int64_t target_id, + const QueueState& after_native) +{ + return enabled && target_destroyed && target_id != 0 && after_native.present && after_native.count > 0 + && after_native.head_target_id == target_id && !after_native.is_engaging + && after_native.last_engaged_target_id != target_id && after_native.pending_target_id != target_id; +} + +constexpr bool IsNoTargetOrRemovedPrefix(std::int64_t target_id, const QueueState& before_native, int removed_prefix) +{ + if (target_id <= 0) { + return true; + } + + for (int index = 0; index < removed_prefix; ++index) { + if (before_native.target_ids[index] == target_id) { + return true; + } + } + return false; +} + +constexpr bool LatchNamesSurvivingTarget(std::int64_t target_id, const QueueState& queue) +{ + if (target_id <= 0) { + return false; + } + + for (int index = 0; index < queue.count; ++index) { + if (queue.target_ids[index] == target_id) { + return true; + } + } + return false; +} + +constexpr bool IsNativePruneResumeCandidate(bool enabled, bool player_fleet_idle, const QueueState& before_native, + const QueueState& after_native) +{ + if (!enabled || !player_fleet_idle || !before_native.present || !after_native.present + || before_native.player_fleet_id == 0 || before_native.player_fleet_id != after_native.player_fleet_id + || before_native.count <= 0 || after_native.count <= 0 || after_native.count >= before_native.count + || before_native.head_target_id == 0 || after_native.head_target_id == 0 + || after_native.head_target_id == before_native.head_target_id || after_native.is_engaging + || before_native.targets_truncated || after_native.targets_truncated + || before_native.captured_target_count != before_native.count + || after_native.captured_target_count != after_native.count) { + return false; + } + + const auto removed_prefix = before_native.count - after_native.count; + for (int index = 0; index < after_native.count; ++index) { + if (after_native.target_ids[index] != before_native.target_ids[index + removed_prefix]) { + return false; + } + } + + return IsNoTargetOrRemovedPrefix(after_native.last_engaged_target_id, before_native, removed_prefix) + && IsNoTargetOrRemovedPrefix(after_native.pending_target_id, before_native, removed_prefix); +} + +constexpr bool IsStableResumePostcondition(const QueueState& expected, const QueueState& confirmed) +{ + if (!expected.present || !confirmed.present || expected.player_fleet_id == 0 + || confirmed.player_fleet_id != expected.player_fleet_id || expected.count <= 0 + || confirmed.count != expected.count || expected.head_target_id == 0 + || confirmed.head_target_id != expected.head_target_id || expected.targets_truncated + || confirmed.targets_truncated || expected.captured_target_count != expected.count + || confirmed.captured_target_count != confirmed.count || confirmed.is_engaging || expected.is_engaging + || confirmed.last_engaged_target_id != expected.last_engaged_target_id + || confirmed.pending_target_id != expected.pending_target_id + || LatchNamesSurvivingTarget(confirmed.last_engaged_target_id, confirmed) + || LatchNamesSurvivingTarget(confirmed.pending_target_id, confirmed)) { + return false; + } + + for (int index = 0; index < expected.count; ++index) { + if (confirmed.target_ids[index] != expected.target_ids[index]) { + return false; + } + } + return true; +} +} // namespace action_queue_guard diff --git a/mods/src/patches/parts/action_queue.cc b/mods/src/patches/parts/action_queue.cc new file mode 100644 index 000000000..e54126c75 --- /dev/null +++ b/mods/src/patches/parts/action_queue.cc @@ -0,0 +1,283 @@ +#include "action_queue.h" +#include + +// The callback ABI below is Windows x64. Validate its contract against the running client. +#if defined(_WIN32) && defined(_M_X64) +#include +#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; +} +} // 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; + } + using method_contract::Resolve; + using method_contract::Pointer; + const auto* engage_info = Resolve(cls, "TryPlanPathAndEngageTarget", false, "Digit.Prime.Combat.EngageResult", + {"Digit.PrimeServer.Models.FleetPlayerData", "Prime.ActionQueue.ActionQueueInstance"}); + auto* engage = Pointer(engage_info); + auto* retry = Pointer(Resolve(cls, "ShouldRetryFailedSetCourse", false, "System.Boolean", + {"System.Int64", "Prime.ActionQueue.ActionQueueInstance"})); + auto* clear = Pointer(Resolve(cls, "StopWatchdogAndClearAllQueues", false, "System.Void", {})); + const auto* info = Resolve(cls, "OnSetCourseResponseEventHandler", false, "System.Void", + {"Digit.PrimeServer.Events.SetCourseResponseEventArgs"}); + auto* course = Pointer(info); + auto* event = info ? il2cpp_class_from_type(info->parameters[0]) : nullptr; + auto* result = engage_info ? il2cpp_class_from_type(engage_info->return_type) : nullptr; + const auto* underlying = result && il2cpp_class_is_enum(result) ? il2cpp_class_enum_basetype(result) : nullptr; + std::uint32_t alignment{}; + const bool valid = + underlying && underlying->type == IL2CPP_TYPE_I4 + && event && info->parameters[0]->type == IL2CPP_TYPE_VALUETYPE + && 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) + && engage && retry && course && clear; + if (!valid) { + spdlog::warn("[FasterQueueRecovery] unavailable: incompatible method signature or queue 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/parts/thin_queue_protection.cc b/mods/src/patches/parts/thin_queue_protection.cc new file mode 100644 index 000000000..9755093ae --- /dev/null +++ b/mods/src/patches/parts/thin_queue_protection.cc @@ -0,0 +1,268 @@ +#include "patches/action_queue_guard_policy.h" +#include + +// Port of the active v2.1.0-guffa.10 guard, not the dormant completion repair. +// Native extents/ABI have been checked for Windows x64 client 262 only. +#if defined(_WIN32) && defined(_M_X64) +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using Object = Il2CppObject; +using action_queue_guard::QueueState; +std::atomic_bool ready{false}; +Il2CppClass * queueClass{}, *actionClass{}, *playerClass{}, *deployedClass{}; +using Id = std::int64_t; +Id (*playerId)(Object*); +Id (*deployedId)(Object*); +int (*playerState)(Object*); +int (*deployedState)(Object*); +int (*removalReason)(Object*); +bool (*destroyed)(Object*); +bool (*battling)(Object*); +int (*tryEngage)(Object*, Object*, Object*); +void (*processTarget)(Object*, Id, bool); + +bool Enabled() +{ return ready.load() && Config::Get().thin_queue_protection && Config::Get().queue_enabled; } + +bool Is(Object* object, Il2CppClass* cls) +{ return object && il2cpp_object_get_class(object) == cls; } + +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; +} + +// Inspect storage without invoking List getters/enumerators. Unknown layouts fail closed. +Il2CppArraySize* List(Object* list, Il2CppClass* element, int limit, int& count) +{ + count = -1; + if (!list) + return nullptr; + 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 || size->type->type != IL2CPP_TYPE_I4 || !storage->type + || storage->type->type != IL2CPP_TYPE_SZARRAY) + return nullptr; + Il2CppArray* items{}; + il2cpp_field_get_value(list, size, &count); + il2cpp_field_get_value(list, storage, &items); + if (count < 0 || count > limit || !items || il2cpp_array_length(items) < static_cast(count) + || il2cpp_class_get_element_class(il2cpp_object_get_class(reinterpret_cast(items))) != element) + return nullptr; + return reinterpret_cast(items); +} + +QueueState Snapshot(Object* queue) +{ + QueueState s; + if (!Is(queue, queueClass)) + return s; + auto* items = List(Read(queue, 0x28), actionClass, 128, s.count); + if (!items) + return {}; + s.player_fleet_id = Read(queue, 0x30); + s.is_engaging = Read(queue, 0x10); + s.last_engaged_target_id = Read(queue, 0x18); + s.pending_target_id = Read(queue, 0x20); + s.captured_target_count = std::min(s.count, static_cast(s.target_ids.size())); + s.targets_truncated = s.count > s.captured_target_count; + for (int i = 0; i < s.captured_target_count; ++i) { + auto* action = static_cast(items->vector[i]); + if (!Is(action, actionClass)) + return {}; + s.target_ids[i] = Read(action, 0x10); + if (!s.target_ids[i]) + return {}; + } + s.head_target_id = s.count ? s.target_ids[0] : 0; + s.present = s.player_fleet_id != 0; + return s; +} + +Object* FindQueue(Object* manager, Id fleet, Id head = 0) +{ + 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; + auto* items = reinterpret_cast(array); + for (unsigned i = 0; i < items->max_length; ++i) { + auto* queue = static_cast(items->vector[i]); + const auto s = Snapshot(queue); + if (s.present && (head ? s.head_target_id == head : s.player_fleet_id == fleet)) + return queue; + } + return nullptr; +} + +bool Resume(Object* manager, Object* queue, Object* player, bool idle, const QueueState& before, + const QueueState& after, const char* source) +{ + if (!action_queue_guard::IsNativePruneResumeCandidate(Enabled(), idle, before, after)) + return false; + const auto confirmed = Snapshot(queue); + if (!action_queue_guard::IsStableResumePostcondition(after, confirmed)) + return false; + // Call the existing native engage entry, including the Faster Queue Recovery detour. + // Never edit queue contents, engagement flags, retry counts or the native watchdog. + const auto result = tryEngage(manager, player, queue); + spdlog::info("[ThinQueueProtection] resume source={} fleet={} old_head={} head={} count={} result={}", source, + confirmed.player_fleet_id, before.head_target_id, confirmed.head_target_id, confirmed.count, result); + return result == action_queue_guard::kEngageResultSuccess; +} + +bool Plan(auto original, Object* manager, Object* player) +{ + if (!Enabled() || !Is(player, playerClass)) + return original(manager, player); + const auto id = playerId(player); + auto* queue = FindQueue(manager, id); + const auto before = Snapshot(queue); + const bool result = original(manager, player); + // Session/queue replacement is not evidence that native pruning removed a prefix. + if (FindQueue(manager, id) != queue) + return result; + const auto after = Snapshot(queue); + const bool idle = !result && before.player_fleet_id == id && playerState(player) == 1; + return Resume(manager, queue, player, idle, before, after, "planner") || result; +} + +void Stall(auto original, Object* manager, Object* queue, Object* player, Object* deployed) +{ + if (!Enabled() || !Is(player, playerClass) || !Is(deployed, deployedClass)) { + original(manager, queue, player, deployed); + return; + } + const auto before = Snapshot(queue); + original(manager, queue, player, deployed); + if (FindQueue(manager, before.player_fleet_id) != queue) + return; + const auto after = Snapshot(queue); + const bool idle = before.player_fleet_id == playerId(player) && before.player_fleet_id == deployedId(deployed) + && playerState(player) == 1 && deployedState(deployed) == 0 && !destroyed(deployed) + && !battling(deployed); + Resume(manager, queue, player, idle, before, after, "watchdog"); +} + +void Disposed(auto original, Object* manager, Object* fleets) +{ + std::array targets{}; + unsigned captured{}; + if (Enabled()) { + int count{}; + if (auto* items = List(fleets, deployedClass, 4096, count)) { + for (int i = 0; i < std::min(count, static_cast(targets.size())); ++i) { + auto* fleet = static_cast(items->vector[i]); + if (Is(fleet, deployedClass) && destroyed(fleet) && removalReason(fleet) == 1) { + const auto id = deployedId(fleet); + if (id && std::find(targets.begin(), targets.begin() + captured, id) == targets.begin() + captured) + targets[captured++] = id; + } + } + } + } + original(manager, fleets); + for (unsigned i = 0; i < captured && Enabled(); ++i) { + const auto target = targets[i]; + auto* queue = FindQueue(manager, 0, target); + const auto before = Snapshot(queue); + if (!action_queue_guard::ShouldProcessDestroyedHead(true, true, target, before)) + continue; + if (FindQueue(manager, 0, target) != queue) + continue; + const auto confirmed = Snapshot(queue); + if (!action_queue_guard::ShouldProcessDestroyedHead(true, true, target, confirmed) + || confirmed.player_fleet_id != before.player_fleet_id || confirmed.count != before.count) + continue; + processTarget(manager, target, false); + spdlog::info("[ThinQueueProtection] process-destroyed-head fleet={} target={}", before.player_fleet_id, target); + } +} + +bool Field(Il2CppClass* cls, const char* name, std::ptrdiff_t offset, Il2CppTypeEnum type) +{ + auto* f = cls ? il2cpp_class_get_field_from_name(cls, name) : nullptr; + return f && f->offset == offset && f->type && f->type->type == type; +} + +template bool Getter(T& out, Il2CppClass* cls, const char* name, const char* result) +{ + const auto* method = method_contract::Resolve(cls, name, false, result, {}); + out = reinterpret_cast(method_contract::Pointer(method)); + if (method && method->return_type->type == IL2CPP_TYPE_VALUETYPE) { + auto* type = il2cpp_class_from_type(method->return_type); + const auto* underlying = type && il2cpp_class_is_enum(type) ? il2cpp_class_enum_basetype(type) : nullptr; + if (!underlying || underlying->type != IL2CPP_TYPE_I4) + out = nullptr; + } + return out != nullptr; +} +} // namespace + +void InstallThinQueueProtection() +{ + if (!Config::Get().thin_queue_protection) + return; + auto* manager = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueManager").get_cls(); + queueClass = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueInstance").get_cls(); + actionClass = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "QueueableAction").get_cls(); + playerClass = il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "FleetPlayerData").get_cls(); + deployedClass = il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "FleetDeployedData").get_cls(); + using method_contract::Pointer; + using method_contract::Resolve; + auto* plan = Pointer(Resolve(manager, "DoPlanPathAndEngageTarget", false, "System.Boolean", + {"Digit.PrimeServer.Models.FleetPlayerData"})); + auto* stall = Pointer(Resolve(manager, "HandleStall", false, "System.Void", + {"Prime.ActionQueue.ActionQueueInstance", "Digit.PrimeServer.Models.FleetPlayerData", + "Digit.PrimeServer.Models.FleetDeployedData"})); + auto* disposed = Pointer(Resolve(manager, "OnFleetsDisposedEventHandler", false, "System.Void", + {"System.Collections.Generic.List"})); + const auto* engage = Resolve(manager, "TryPlanPathAndEngageTarget", false, "Digit.Prime.Combat.EngageResult", + {"Digit.PrimeServer.Models.FleetPlayerData", "Prime.ActionQueue.ActionQueueInstance"}); + tryEngage = reinterpret_cast(Pointer(engage)); + processTarget = reinterpret_cast( + Pointer(Resolve(manager, "ProcessQueue", false, "System.Void", {"System.Int64", "System.Boolean"}))); + auto* result = engage ? il2cpp_class_from_type(engage->return_type) : nullptr; + const auto* underlying = result && il2cpp_class_is_enum(result) ? il2cpp_class_enum_basetype(result) : nullptr; + const bool valid = + manager && queueClass && actionClass && playerClass && deployedClass && plan && stall && disposed && tryEngage + && processTarget && underlying && underlying->type == IL2CPP_TYPE_I4 + && Field(manager, "_battleQueue", 0x48, IL2CPP_TYPE_SZARRAY) + && Field(queueClass, "IsEngaging", 0x10, IL2CPP_TYPE_BOOLEAN) + && Field(queueClass, "LastEngagedTargetId", 0x18, IL2CPP_TYPE_I8) + && Field(queueClass, "PendingEngageTargetId", 0x20, IL2CPP_TYPE_I8) + && Field(queueClass, "_actionQueue", 0x28, IL2CPP_TYPE_GENERICINST) + && Field(queueClass, "k__BackingField", 0x30, IL2CPP_TYPE_I8) + && Field(actionClass, "k__BackingField", 0x10, IL2CPP_TYPE_I8) + && Getter(playerId, playerClass, "get_Id", "System.Int64") + && Getter(playerState, playerClass, "get_CurrentState", "Digit.PrimeServer.Models.FleetState") + && Getter(deployedId, deployedClass, "get_ID", "System.Int64") + && Getter(deployedState, deployedClass, "get_CurrentState", "Digit.PrimeServer.Models.DeployedFleetState") + && Getter(removalReason, deployedClass, "get_RemovalReason", "Digit.PrimeServer.Models.FleetRemovalReason") + && Getter(destroyed, deployedClass, "get_IsDestroyed", "System.Boolean") + && Getter(battling, deployedClass, "get_CurrentlyBattling", "System.Boolean"); + if (!valid) { + spdlog::warn("[ThinQueueProtection] unavailable: incompatible methods or queue layout"); + return; + } + const bool a = SPUD_STATIC_DETOUR(plan, Plan) != nullptr; + const bool b = SPUD_STATIC_DETOUR(stall, Stall) != nullptr; + const bool c = SPUD_STATIC_DETOUR(disposed, Disposed) != nullptr; + ready.store(a && b && c); + spdlog::info("[ThinQueueProtection] ready={}", ready.load()); +} +#else +void InstallThinQueueProtection() {} +#endif diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index 9579b9692..13784fa20 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -48,6 +48,8 @@ void InstallDoubleClickAssignShipHooks(); void InstallInstantWarpConfirmationHooks(); void InstallForbiddenTechConfirmationHooks(); void InstallAudioEventHooks(); +void InstallActionQueueRecovery(); +void InstallThinQueueProtection(); __int64 il2cpp_init_hook(auto original, const char* domain_name) { @@ -171,6 +173,9 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) } } + InstallActionQueueRecovery(); + InstallThinQueueProtection(); + spdlog::info(""); #if VERSION_PATCH 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..b619d3926 --- /dev/null +++ b/tests/run-action-queue.ps1 @@ -0,0 +1,16 @@ +# 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.' } + & clang++ -std=c++23 -Imods/src tests/thin_queue_protection.cc -o build/thin_queue_protection_tests.exe + if ($LASTEXITCODE -ne 0) { throw 'Thin Queue Protection test compilation failed.' } + & ./build/thin_queue_protection_tests.exe + if ($LASTEXITCODE -ne 0) { throw 'Thin Queue Protection regression failed.' } +} finally { Pop-Location } diff --git a/tests/thin_queue_protection.cc b/tests/thin_queue_protection.cc new file mode 100644 index 000000000..89ea48e99 --- /dev/null +++ b/tests/thin_queue_protection.cc @@ -0,0 +1,230 @@ +#include "patches/action_queue_guard_policy.h" + +#include +#include +#include + +namespace +{ +using action_queue_guard::QueueState; + +QueueState Queue(int count, std::int64_t head) +{ + auto queue = QueueState{ + .present = true, + .player_fleet_id = 42, + .count = count, + .head_target_id = head, + }; + if (count > 0) { + queue.target_ids[0] = head; + queue.captured_target_count = count; + } + return queue; +} + +QueueState Queue(std::initializer_list targets) +{ + QueueState queue{ + .present = true, + .player_fleet_id = 42, + .count = static_cast(targets.size()), + }; + int index = 0; + for (const auto target : targets) { + queue.target_ids[index++] = target; + } + queue.captured_target_count = queue.count; + queue.head_target_id = queue.count > 0 ? queue.target_ids[0] : 0; + return queue; +} +} // namespace + +int main() +{ + // module installs for protection or diagnostics + { + assert(!(action_queue_guard::ShouldInstall(false, false))); + assert(action_queue_guard::ShouldInstall(true, false)); + assert(action_queue_guard::ShouldInstall(false, true)); + assert(action_queue_guard::ShouldInstall(true, true)); + } + + // native engage results have stable diagnostic names + { + assert(action_queue_guard::EngageResultName(0).compare("success") == 0); + assert(action_queue_guard::EngageResultName(1).compare("skip-target") == 0); + assert(action_queue_guard::EngageResultName(2).compare("stop") == 0); + assert(action_queue_guard::EngageResultName(-1).compare("not-attempted") == 0); + assert(action_queue_guard::EngageResultName(99).compare("unknown") == 0); + } + + // destroyed head processing is exact and feature gated + { + const auto queue = Queue(2, 101); + + assert(action_queue_guard::ShouldProcessDestroyedHead(true, true, 101, queue)); + assert(!(action_queue_guard::ShouldProcessDestroyedHead(false, true, 101, queue))); + assert(!(action_queue_guard::ShouldProcessDestroyedHead(true, false, 101, queue))); + assert(!(action_queue_guard::ShouldProcessDestroyedHead(true, true, 202, queue))); + assert(!(action_queue_guard::ShouldProcessDestroyedHead(true, true, 0, queue))); + } + + // destroyed head processing refuses active queue latches + { + auto queue = Queue(2, 101); + + queue.is_engaging = true; + assert(!(action_queue_guard::ShouldProcessDestroyedHead(true, true, 101, queue))); + + queue.is_engaging = false; + queue.last_engaged_target_id = 101; + assert(!(action_queue_guard::ShouldProcessDestroyedHead(true, true, 101, queue))); + + queue.last_engaged_target_id = 0; + queue.pending_target_id = 101; + assert(!(action_queue_guard::ShouldProcessDestroyedHead(true, true, 101, queue))); + } + + // local kill and repeated disposal are no-ops once the exact target is absent + { + const auto next_head = Queue(1, 202); + const auto empty = Queue(0, 0); + + assert(!(action_queue_guard::ShouldProcessDestroyedHead(true, true, 101, next_head))); + assert(!(action_queue_guard::ShouldProcessDestroyedHead(true, true, 101, empty))); + } + + // native prune identifies a stranded new-head resume candidate + { + const auto before = Queue({101, 202, 303, 404}); + const auto after = Queue({303, 404}); + + assert(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after)); + } + + // temporary unavailability without native removal is not a candidate + { + const auto before = Queue({101, 202}); + const auto after = Queue({101, 202}); + + assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after))); + } + + // resume requires idle state and rejects latches on the surviving suffix + { + const auto before = Queue({101, 202, 303}); + + auto after = Queue({202, 303}); + assert(!(action_queue_guard::IsNativePruneResumeCandidate(false, true, before, after))); + assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, false, before, after))); + + after.is_engaging = true; + assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after))); + + after.is_engaging = false; + after.last_engaged_target_id = 202; + assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after))); + + after.last_engaged_target_id = 0; + after.pending_target_id = 202; + assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after))); + } + + // resume accepts no-target sentinels and stale latches from the removed prefix + { + const auto before = Queue({101, 202, 303, 404}); + + auto after = Queue({303, 404}); + after.last_engaged_target_id = 101; + after.pending_target_id = 202; + assert(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after)); + + after.last_engaged_target_id = -1; + after.pending_target_id = -1; + assert(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after)); + } + + // resume rejects unrelated positive native latches + { + const auto before = Queue({101, 202, 303}); + + auto after = Queue({303}); + after.pending_target_id = 909; + assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after))); + + after.pending_target_id = 0; + after.last_engaged_target_id = 909; + assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after))); + } + + // manual clear and repeated watchdog callbacks are no-ops + { + const auto before = Queue({101, 202}); + const auto empty = Queue(0, 0); + const auto stable = Queue({202}); + + assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, empty))); + assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, stable, stable))); + } + + // resume fails closed on reorder truncation and fleet replacement + { + const auto before = Queue({101, 202, 303, 404}); + + auto after = Queue({404, 303}); + assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after))); + + after = Queue({303, 404}); + after.targets_truncated = true; + assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after))); + + after = Queue({303, 404}); + after.player_fleet_id = 84; + assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after))); + } + + // resume postcondition requires the exact same idle surviving suffix + { + const auto expected = Queue({303, 404}); + + assert(action_queue_guard::IsStableResumePostcondition(expected, expected)); + + auto confirmed = expected; + confirmed.target_ids[1] = 505; + assert(!(action_queue_guard::IsStableResumePostcondition(expected, confirmed))); + + confirmed = expected; + confirmed.is_engaging = true; + assert(!(action_queue_guard::IsStableResumePostcondition(expected, confirmed))); + + confirmed = expected; + confirmed.pending_target_id = 303; + assert(!(action_queue_guard::IsStableResumePostcondition(expected, confirmed))); + + auto stale_latch = expected; + stale_latch.last_engaged_target_id = 101; + stale_latch.pending_target_id = 202; + assert(action_queue_guard::IsStableResumePostcondition(stale_latch, stale_latch)); + + confirmed = stale_latch; + confirmed.pending_target_id = 101; + assert(!(action_queue_guard::IsStableResumePostcondition(stale_latch, confirmed))); + + stale_latch.pending_target_id = 303; + assert(!(action_queue_guard::IsStableResumePostcondition(stale_latch, stale_latch))); + + confirmed = expected; + confirmed.player_fleet_id = 84; + assert(!(action_queue_guard::IsStableResumePostcondition(expected, confirmed))); + + confirmed = expected; + confirmed.count = 1; + confirmed.head_target_id = 404; + confirmed.target_ids[0] = 404; + confirmed.target_ids[1] = 0; + confirmed.captured_target_count = 1; + assert(!(action_queue_guard::IsStableResumePostcondition(expected, confirmed))); + } + std::cout << "Thin Queue Protection release-policy regressions passed\n"; +} From 4d857adb7ad8f992e51ae6d6bb83ab62323b3777 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 27 Sep 2026 22:25:41 -0500 Subject: [PATCH 3/5] Complete queue config examples and reuse class lookup helper --- docs/FASTER_QUEUE_RECOVERY.md | 2 +- example_community_patch_settings_da.toml | 6 ++++++ ...community_patch_settings_en-GB-x-cockney.toml | 7 +++++++ ..._community_patch_settings_en-x-minionese.toml | 7 +++++++ example_community_patch_settings_es.toml | 6 ++++++ example_community_patch_settings_ru.toml | 6 ++++++ example_community_patch_settings_tlh.toml | 7 +++++++ mods/src/patches/parts/action_queue.cc | 16 ++++------------ 8 files changed, 44 insertions(+), 13 deletions(-) diff --git a/docs/FASTER_QUEUE_RECOVERY.md b/docs/FASTER_QUEUE_RECOVERY.md index f21e3b8ca..4f9ec6be7 100644 --- a/docs/FASTER_QUEUE_RECOVERY.md +++ b/docs/FASTER_QUEUE_RECOVERY.md @@ -34,7 +34,7 @@ The hooked methods are `TryPlanPathAndEngageTarget`, `ShouldRetryFailedSetCourse 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. +The older off-screen Kir'Shara combat-completion repair was fixed by Scopely. That workaround and its `kirshara_queue_repair` setting have already been removed; neither is a prerequisite for Faster Queue Recovery. This feature addresses only the separate unavailable-target/course-response race described above. ## Validation diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index f0678b63b..9d5daf505 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -94,6 +94,12 @@ hotkeys_extended = true # Skal køen være aktiveret som standard, hvis du har Kir'Shara-artefakten? queue_enabled = true +# Hurtigere køgendannelse, når et ventende mål forsvinder (kun Windows x64). +faster_queue_recovery = false + +# Beskytter køen efter oprydning af mål (kun Windows x64; aktiveret som standard). +thin_queue_protection = true + # Sæt denne til sand, hvis du foretrækker at bruge Scopelys genvejstaster use_scopely_hotkeys = false diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index e031317f0..b08af0df4 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -94,6 +94,13 @@ hotkeys_extended = true # If you have the Kir'Shara artifact, should the queue be enabled by default? queue_enabled = true +# Faster Queue Recovery: advance after an outstanding target disappears (Windows x64 only). +faster_queue_recovery = false + +# Thin Queue Protection: resume after native pruning; clean up confirmed destroyed heads. +# Windows x64 only. Enabled by default; independent of Faster Queue Recovery. +thin_queue_protection = true + # If you prefer to use Scopely's hotkeys set this to true use_scopely_hotkeys = false diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index f9c39e4f8..a959a56bf 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -94,6 +94,13 @@ hotkeys_extended = true # If you have the Kir'Shara artifact, should the queue be enabled by default? queue_enabled = true +# Faster Queue Recovery: advance after an outstanding target disappears (Windows x64 only). +faster_queue_recovery = false + +# Thin Queue Protection: resume after native pruning; clean up confirmed destroyed heads. +# Windows x64 only. Enabled by default; independent of Faster Queue Recovery. +thin_queue_protection = true + # If you prefer to use Scopely's hotkeys set this to true use_scopely_hotkeys = false diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index 635a50b8c..957f72be6 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -94,6 +94,12 @@ hotkeys_extended = true # Si tienes el artefacto Kir'Shara, ¿debe activarse la cola de forma predeterminada? queue_enabled = true +# Recuperación rápida de la cola cuando desaparece un objetivo pendiente (solo Windows x64). +faster_queue_recovery = false + +# Protege la cola tras eliminar objetivos (solo Windows x64; activado por defecto). +thin_queue_protection = true + # Si prefieres usar los atajos de Scopely, establece este valor en verdadero use_scopely_hotkeys = false diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index c0c4cd050..153c37dd2 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -94,6 +94,12 @@ hotkeys_extended = true # Следует ли включать очередь по умолчанию при наличии артефакта Кир'Шара? queue_enabled = true +# Быстрое восстановление очереди, если ожидаемая цель исчезла (только Windows x64). +faster_queue_recovery = false + +# Защита очереди после удаления целей (только Windows x64; включена по умолчанию). +thin_queue_protection = true + # Включите этот параметр, если предпочитаете горячие клавиши Scopely use_scopely_hotkeys = false diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index 7044b7c0e..b5e3ab8cb 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -94,6 +94,13 @@ hotkeys_extended = true # If you have Kir'Shara artifact, should queue be chu' by motlh? queue_enabled = true +# Faster Queue Recovery: advance after an outstanding target disappears (Windows x64 only). +faster_queue_recovery = false + +# Thin Queue Protection: resume after native pruning; clean up confirmed destroyed heads. +# Windows x64 only. Enabled by default; independent of Faster Queue Recovery. +thin_queue_protection = true + # If you prefer Daq lo' Scopely's hotkeys cher this Daq teH use_scopely_hotkeys = false diff --git a/mods/src/patches/parts/action_queue.cc b/mods/src/patches/parts/action_queue.cc index e54126c75..e502fbf96 100644 --- a/mods/src/patches/parts/action_queue.cc +++ b/mods/src/patches/parts/action_queue.cc @@ -45,14 +45,6 @@ template T Read(const void* object, std::size_t offset) 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) @@ -229,10 +221,10 @@ 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"); + auto* cls = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueManager").get_cls(); + queueClass = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueInstance").get_cls(); + actionClass = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "QueueableAction").get_cls(); + int64Class = il2cpp_get_class_helper("mscorlib", "System", "Int64").get_cls(); if (!cls || !queueClass || !actionClass || !int64Class) { spdlog::warn("[FasterQueueRecovery] unavailable: native types not found"); return; From ff75bb503486620e5fb7ec1fb88214a5fbec876f Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 22:00:34 -0500 Subject: [PATCH 4/5] Separate queue hook installation from runtime recovery preferences --- docs/FASTER_QUEUE_RECOVERY.md | 6 ++- docs/THIN_QUEUE_PROTECTION.md | 6 ++- example_community_patch_settings_en.toml | 3 ++ mods/src/config.cc | 4 ++ mods/src/config.h | 2 + mods/src/defaultconfig.h | 2 + mods/src/patches/parts/action_queue.cc | 31 ++++++++++++---- mods/src/patches/parts/action_queue.h | 3 ++ mods/src/patches/parts/hotkeys.cc | 2 + .../patches/parts/thin_queue_protection.cc | 37 ++++++++++++------- mods/src/patches/patches.cc | 5 +-- 11 files changed, 72 insertions(+), 29 deletions(-) diff --git a/docs/FASTER_QUEUE_RECOVERY.md b/docs/FASTER_QUEUE_RECOVERY.md index 4f9ec6be7..82bb730fa 100644 --- a/docs/FASTER_QUEUE_RECOVERY.md +++ b/docs/FASTER_QUEUE_RECOVERY.md @@ -2,13 +2,15 @@ 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: +Enable the opt-in recovery preference: ```toml [control] faster_queue_recovery = true ``` +Following #313, default-enabled `[patches].actionqueuerecoveryhooks` independently controls installation. Changing that installation switch requires a restart. Installed callbacks check the current `control.faster_queue_recovery` and `control.queue_enabled` preferences; changing TOML still requires reload/restart. + The feature also respects `control.queue_enabled`. Hooks are supported on Windows x64 when the required method signatures and queue layout are compatible. Other platforms do not install these hooks; incompatible Windows layouts log an unavailable message and retain native behavior. ## Behavior @@ -22,7 +24,7 @@ The adapter records the latest engagement attempt for up to eight fleets, using 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. +Queue storage is bounded and validated. Unknown layouts/content are ineligible. Expired/replaced requests release their weak handles; native session cleanup clears all records. Engage, Retry and Course discard pending recovery records when they observe disabled behavior. Both Toggle Queue transitions also clear them, so an off/on toggle cannot reuse authorization from before the toggle. 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. diff --git a/docs/THIN_QUEUE_PROTECTION.md b/docs/THIN_QUEUE_PROTECTION.md index ff2807792..6afe237e5 100644 --- a/docs/THIN_QUEUE_PROTECTION.md +++ b/docs/THIN_QUEUE_PROTECTION.md @@ -10,8 +10,10 @@ thin_queue_protection = true Enabled by default on Windows x64. Also respects `control.queue_enabled`. The old `advanced.queue.thin_queue_protection` value is used when the new control key is -absent, preserving an explicit opt-out. Hook installation requires a restart; -enabling a setting later does not install missing hooks. +absent, preserving an explicit opt-out. Default-enabled `[patches].thinqueueprotectionhooks` +controls installation independently. Installed callbacks check the current feature and queue +preferences. Changing the installation switch requires restart; TOML changes require +reload/restart. The native planner (`DoPlanPathAndEngageTarget`) and watchdog (`HandleStall`) run first. If they removed an exact prefix of targets, left a nonempty unchanged diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 770c098fb..27718f2ad 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -270,6 +270,9 @@ loadingtiphooks = true doubleclickassignshiphooks = true forbiddentechconfirmhooks = true audioeventhooks = true +# Install queue hooks independently of the current control preferences. +actionqueuerecoveryhooks = true +thinqueueprotectionhooks = true freeresizehooks = true game_version = true giftsbulkclaimhooks = true diff --git a/mods/src/config.cc b/mods/src/config.cc index 68fa954ea..8eaa5d2e0 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -923,6 +923,10 @@ void Config::Load() get_config_or_default(config, parsed, "patches", "doubleclickassignshiphooks", DCP::doubleclickassignshiphooks, write_config); this->installForbiddenTechConfirmationHooks = get_config_or_default(config, parsed, "patches", "forbiddentechconfirmhooks", DCP::forbiddentechconfirmhooks, write_config); + this->installActionQueueRecoveryHooks = + get_config_or_default(config, parsed, "patches", "actionqueuerecoveryhooks", DCP::actionqueuerecoveryhooks, write_config); + this->installThinQueueProtectionHooks = + get_config_or_default(config, parsed, "patches", "thinqueueprotectionhooks", DCP::thinqueueprotectionhooks, write_config); this->installAudioEventHooks = get_config_or_default(config, parsed, "patches", "audioeventhooks", DCP::audioeventhooks, write_config); this->installInstantCargoCounterHooks = diff --git a/mods/src/config.h b/mods/src/config.h index b850d930c..eb55cbafa 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -280,6 +280,8 @@ class Config final bool installForbiddenTechConfirmationHooks; bool installInstantWarpConfirmationHooks; bool installAudioEventHooks; + bool installActionQueueRecoveryHooks; + bool installThinQueueProtectionHooks; std::string config_settings_url; std::string config_assets_url_override; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index f5e126022..975e8a971 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -96,6 +96,8 @@ namespace Patches constexpr bool doubleclickassignshiphooks = true; constexpr bool forbiddentechconfirmhooks = true; constexpr bool audioeventhooks = true; + constexpr bool actionqueuerecoveryhooks = true; + constexpr bool thinqueueprotectionhooks = true; constexpr bool instantcargocounterhooks = true; constexpr bool cargoformathooks = true; // on by default: cargo number precision override constexpr bool officersorthooks = true; // restore Below Deck Ability sort option diff --git a/mods/src/patches/parts/action_queue.cc b/mods/src/patches/parts/action_queue.cc index e502fbf96..712ffa1ca 100644 --- a/mods/src/patches/parts/action_queue.cc +++ b/mods/src/patches/parts/action_queue.cc @@ -62,7 +62,9 @@ QueueState Inspect(Il2CppObject* queue, std::int64_t target = 0) 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) + || storage->type->type != IL2CPP_TYPE_SZARRAY || size->type->byref || storage->type->byref + || (il2cpp_field_get_flags(size) & FIELD_ATTRIBUTE_STATIC) + || (il2cpp_field_get_flags(storage) & FIELD_ATTRIBUTE_STATIC)) return s; Il2CppArray* items{}; il2cpp_field_get_value(list, size, &s.count); @@ -151,6 +153,8 @@ int Engage(auto original, Il2CppObject* manager, Il2CppObject* player, Il2CppObj } bool Retry(auto original, Il2CppObject* manager, std::int64_t target, Il2CppObject* queue) { + if (!Enabled()) + ClearRequests(); const bool retry = original(manager, target, queue); if (retry || !Enabled() || !currentCourse) return retry; @@ -183,6 +187,8 @@ struct CourseResponse { static_assert(sizeof(CourseResponse) == 24 && offsetof(CourseResponse, target) == 16); void Course(auto original, Il2CppObject* manager, CourseResponse args) { + if (!Enabled()) + ClearRequests(); CourseContext context{args.fleet}; if (Enabled() && !args.success && !args.recall) { try { @@ -213,14 +219,16 @@ void ClearAll(auto original, Il2CppObject* 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; + return field && field->offset == offset && field->type && !field->type->byref + && !(il2cpp_field_get_flags(field) & FIELD_ATTRIBUTE_STATIC) && field->type->type == type; } } // namespace +void ClearActionQueueRecoveryRequests() +{ ClearRequests(); } + void InstallActionQueueRecovery() { - if (!Config::Get().faster_queue_recovery) - return; auto* cls = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueManager").get_cls(); queueClass = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueInstance").get_cls(); actionClass = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "QueueableAction").get_cls(); @@ -234,9 +242,11 @@ void InstallActionQueueRecovery() const auto* engage_info = Resolve(cls, "TryPlanPathAndEngageTarget", false, "Digit.Prime.Combat.EngageResult", {"Digit.PrimeServer.Models.FleetPlayerData", "Prime.ActionQueue.ActionQueueInstance"}); auto* engage = Pointer(engage_info); - auto* retry = Pointer(Resolve(cls, "ShouldRetryFailedSetCourse", false, "System.Boolean", - {"System.Int64", "Prime.ActionQueue.ActionQueueInstance"})); - auto* clear = Pointer(Resolve(cls, "StopWatchdogAndClearAllQueues", false, "System.Void", {})); + const auto* retry_info = Resolve(cls, "ShouldRetryFailedSetCourse", false, "System.Boolean", + {"System.Int64", "Prime.ActionQueue.ActionQueueInstance"}); + auto* retry = Pointer(retry_info); + const auto* clear_info = Resolve(cls, "StopWatchdogAndClearAllQueues", false, "System.Void", {}); + auto* clear = Pointer(clear_info); const auto* info = Resolve(cls, "OnSetCourseResponseEventHandler", false, "System.Void", {"Digit.PrimeServer.Events.SetCourseResponseEventArgs"}); auto* course = Pointer(info); @@ -258,7 +268,11 @@ void InstallActionQueueRecovery() && Field(queueClass, "k__BackingField", 0x30, IL2CPP_TYPE_I8) && Field(queueClass, "_actionQueue", 0x28, IL2CPP_TYPE_GENERICINST) && Field(actionClass, "k__BackingField", 0x10, IL2CPP_TYPE_I8) - && engage && retry && course && clear; + && engage && retry && course && clear + && !engage_info->has_full_generic_sharing_signature && !retry_info->has_full_generic_sharing_signature + && !info->has_full_generic_sharing_signature && !clear_info->has_full_generic_sharing_signature + && engage != retry && engage != course && engage != clear + && retry != course && retry != clear && course != clear; if (!valid) { spdlog::warn("[FasterQueueRecovery] unavailable: incompatible method signature or queue layout"); return; @@ -272,4 +286,5 @@ void InstallActionQueueRecovery() } #else void InstallActionQueueRecovery() {} +void ClearActionQueueRecoveryRequests() {} #endif diff --git a/mods/src/patches/parts/action_queue.h b/mods/src/patches/parts/action_queue.h index 3e0f1e06e..92c71b7a7 100644 --- a/mods/src/patches/parts/action_queue.h +++ b/mods/src/patches/parts/action_queue.h @@ -4,6 +4,9 @@ #include #include +// Invalidate pending recovery authorization at both queue toggle transitions. +void ClearActionQueueRecoveryRequests(); + namespace action_queue { using Clock = std::chrono::steady_clock; diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index fb3129d63..5170021a4 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -40,6 +40,7 @@ #include "prime/ShipManagementScreenStateViewController.h" #include "prime/ShortcutsManager.h" +#include "patches/parts/action_queue.h" #include "patches/key.h" #include "patches/mapkey.h" #include "patches/parts/daily_faction_bulk_claim.h" @@ -783,6 +784,7 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) if ((MapKey::IsDown(GameFunction::ToggleQueue))) { config->queue_enabled = !config->queue_enabled; + ClearActionQueueRecoveryRequests(); return; } diff --git a/mods/src/patches/parts/thin_queue_protection.cc b/mods/src/patches/parts/thin_queue_protection.cc index 9755093ae..26d5278a3 100644 --- a/mods/src/patches/parts/thin_queue_protection.cc +++ b/mods/src/patches/parts/thin_queue_protection.cc @@ -2,7 +2,7 @@ #include // Port of the active v2.1.0-guffa.10 guard, not the dormant completion repair. -// Native extents/ABI have been checked for Windows x64 client 262 only. +// Windows x64 callback ABI is validated against running metadata; current native fit is recorded separately. #if defined(_WIN32) && defined(_M_X64) #include #include @@ -53,7 +53,9 @@ Il2CppArraySize* List(Object* list, Il2CppClass* element, int limit, int& count) 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 || size->type->type != IL2CPP_TYPE_I4 || !storage->type - || storage->type->type != IL2CPP_TYPE_SZARRAY) + || storage->type->type != IL2CPP_TYPE_SZARRAY || size->type->byref || storage->type->byref + || (il2cpp_field_get_flags(size) & FIELD_ATTRIBUTE_STATIC) + || (il2cpp_field_get_flags(storage) & FIELD_ATTRIBUTE_STATIC)) return nullptr; Il2CppArray* items{}; il2cpp_field_get_value(list, size, &count); @@ -194,13 +196,15 @@ void Disposed(auto original, Object* manager, Object* fleets) bool Field(Il2CppClass* cls, const char* name, std::ptrdiff_t offset, Il2CppTypeEnum type) { auto* f = cls ? il2cpp_class_get_field_from_name(cls, name) : nullptr; - return f && f->offset == offset && f->type && f->type->type == type; + return f && f->offset == offset && f->type && !f->type->byref + && !(il2cpp_field_get_flags(f) & FIELD_ATTRIBUTE_STATIC) && f->type->type == type; } template bool Getter(T& out, Il2CppClass* cls, const char* name, const char* result) { const auto* method = method_contract::Resolve(cls, name, false, result, {}); - out = reinterpret_cast(method_contract::Pointer(method)); + out = method && !method->has_full_generic_sharing_signature + ? reinterpret_cast(method_contract::Pointer(method)) : nullptr; if (method && method->return_type->type == IL2CPP_TYPE_VALUETYPE) { auto* type = il2cpp_class_from_type(method->return_type); const auto* underlying = type && il2cpp_class_is_enum(type) ? il2cpp_class_enum_basetype(type) : nullptr; @@ -213,8 +217,6 @@ template bool Getter(T& out, Il2CppClass* cls, const char* name, co void InstallThinQueueProtection() { - if (!Config::Get().thin_queue_protection) - return; auto* manager = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueManager").get_cls(); queueClass = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueInstance").get_cls(); actionClass = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "QueueableAction").get_cls(); @@ -222,23 +224,30 @@ void InstallThinQueueProtection() deployedClass = il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "FleetDeployedData").get_cls(); using method_contract::Pointer; using method_contract::Resolve; - auto* plan = Pointer(Resolve(manager, "DoPlanPathAndEngageTarget", false, "System.Boolean", - {"Digit.PrimeServer.Models.FleetPlayerData"})); - auto* stall = Pointer(Resolve(manager, "HandleStall", false, "System.Void", + const auto* plan_info = Resolve(manager, "DoPlanPathAndEngageTarget", false, "System.Boolean", + {"Digit.PrimeServer.Models.FleetPlayerData"}); + auto* plan = Pointer(plan_info); + const auto* stall_info = Resolve(manager, "HandleStall", false, "System.Void", {"Prime.ActionQueue.ActionQueueInstance", "Digit.PrimeServer.Models.FleetPlayerData", - "Digit.PrimeServer.Models.FleetDeployedData"})); - auto* disposed = Pointer(Resolve(manager, "OnFleetsDisposedEventHandler", false, "System.Void", - {"System.Collections.Generic.List"})); + "Digit.PrimeServer.Models.FleetDeployedData"}); + auto* stall = Pointer(stall_info); + const auto* disposed_info = Resolve(manager, "OnFleetsDisposedEventHandler", false, "System.Void", + {"System.Collections.Generic.List"}); + auto* disposed = Pointer(disposed_info); const auto* engage = Resolve(manager, "TryPlanPathAndEngageTarget", false, "Digit.Prime.Combat.EngageResult", {"Digit.PrimeServer.Models.FleetPlayerData", "Prime.ActionQueue.ActionQueueInstance"}); tryEngage = reinterpret_cast(Pointer(engage)); - processTarget = reinterpret_cast( - Pointer(Resolve(manager, "ProcessQueue", false, "System.Void", {"System.Int64", "System.Boolean"}))); + const auto* process_info = Resolve(manager, "ProcessQueue", false, "System.Void", {"System.Int64", "System.Boolean"}); + processTarget = reinterpret_cast(Pointer(process_info)); auto* result = engage ? il2cpp_class_from_type(engage->return_type) : nullptr; const auto* underlying = result && il2cpp_class_is_enum(result) ? il2cpp_class_enum_basetype(result) : nullptr; const bool valid = manager && queueClass && actionClass && playerClass && deployedClass && plan && stall && disposed && tryEngage && processTarget && underlying && underlying->type == IL2CPP_TYPE_I4 + && !plan_info->has_full_generic_sharing_signature && !stall_info->has_full_generic_sharing_signature + && !disposed_info->has_full_generic_sharing_signature && !engage->has_full_generic_sharing_signature + && !process_info->has_full_generic_sharing_signature + && plan != stall && plan != disposed && stall != disposed && Field(manager, "_battleQueue", 0x48, IL2CPP_TYPE_SZARRAY) && Field(queueClass, "IsEngaging", 0x10, IL2CPP_TYPE_BOOLEAN) && Field(queueClass, "LastEngagedTargetId", 0x18, IL2CPP_TYPE_I8) diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index bbf85c04c..163b49e97 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -154,6 +154,8 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) {"InstantWarpConfirm", {InstallInstantWarpConfirmationHooks, &cfg.installInstantWarpConfirmationHooks}}, {"ForbiddenTechConfirm", {InstallForbiddenTechConfirmationHooks, &cfg.installForbiddenTechConfirmationHooks}}, {"AudioEvents", {InstallAudioEventHooks, &cfg.installAudioEventHooks}}, + {"ActionQueueRecovery", {InstallActionQueueRecovery, &cfg.installActionQueueRecoveryHooks}}, + {"ThinQueueProtection", {InstallThinQueueProtection, &cfg.installThinQueueProtectionHooks}}, }; printf("il2cpp_init_hook(%s)\n", domain_name); @@ -174,9 +176,6 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) } } - InstallActionQueueRecovery(); - InstallThinQueueProtection(); - spdlog::info(""); #if VERSION_PATCH From c60b6f8325947e1c0578367a11830d300089c609 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 22:05:27 -0500 Subject: [PATCH 5/5] Remove obsolete feature-derived queue installation helper --- mods/src/patches/action_queue_guard_policy.h | 3 --- tests/thin_queue_protection.cc | 8 -------- 2 files changed, 11 deletions(-) diff --git a/mods/src/patches/action_queue_guard_policy.h b/mods/src/patches/action_queue_guard_policy.h index 418746235..1bfc3eab9 100644 --- a/mods/src/patches/action_queue_guard_policy.h +++ b/mods/src/patches/action_queue_guard_policy.h @@ -37,9 +37,6 @@ struct QueueState { std::int64_t pending_target_id = 0; }; -constexpr bool ShouldInstall(bool protection_enabled, bool diagnostics_enabled) -{ return protection_enabled || diagnostics_enabled; } - constexpr bool ShouldProcessDestroyedHead(bool enabled, bool target_destroyed, std::int64_t target_id, const QueueState& after_native) { diff --git a/tests/thin_queue_protection.cc b/tests/thin_queue_protection.cc index 89ea48e99..34ae5330b 100644 --- a/tests/thin_queue_protection.cc +++ b/tests/thin_queue_protection.cc @@ -42,14 +42,6 @@ QueueState Queue(std::initializer_list targets) int main() { - // module installs for protection or diagnostics - { - assert(!(action_queue_guard::ShouldInstall(false, false))); - assert(action_queue_guard::ShouldInstall(true, false)); - assert(action_queue_guard::ShouldInstall(false, true)); - assert(action_queue_guard::ShouldInstall(true, true)); - } - // native engage results have stable diagnostic names { assert(action_queue_guard::EngageResultName(0).compare("success") == 0);