diff --git a/docs/FASTER_QUEUE_RECOVERY.md b/docs/FASTER_QUEUE_RECOVERY.md new file mode 100644 index 000000000..82bb730fa --- /dev/null +++ b/docs/FASTER_QUEUE_RECOVERY.md @@ -0,0 +1,45 @@ +# 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 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 + +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. 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. + +## 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. + +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 + +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..6afe237e5 --- /dev/null +++ b/docs/THIN_QUEUE_PROTECTION.md @@ -0,0 +1,50 @@ +# 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. 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 +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_da.toml b/example_community_patch_settings_da.toml index 2069b388a..e565e37a6 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_de.toml b/example_community_patch_settings_de.toml index dc626072b..0c10a9fb0 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-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index e1aed8205..b8c9239e9 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 562af2608..09944316f 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_en.toml b/example_community_patch_settings_en.toml index 8dcc031a6..27718f2ad 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 @@ -263,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/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index 795c8f1c8..004aad203 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_fr.toml b/example_community_patch_settings_fr.toml index 8bf856ea3..57c102613 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 719b54a1d..3016a0bc5 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/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index 6e1d0ca2a..eec9b9bf8 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 ca88ac0b1..9ba9e214d 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/config.cc b/mods/src/config.cc index b69377fbb..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 = @@ -934,6 +938,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 2ab320ce8..eb55cbafa 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -181,6 +181,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; @@ -278,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 10764b99f..975e8a971 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 @@ -94,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/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..1bfc3eab9 --- /dev/null +++ b/mods/src/patches/action_queue_guard_policy.h @@ -0,0 +1,123 @@ +#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 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..712ffa1ca --- /dev/null +++ b/mods/src/patches/parts/action_queue.cc @@ -0,0 +1,290 @@ +#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; +} +// 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 || 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); + 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) +{ + if (!Enabled()) + ClearRequests(); + 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) +{ + if (!Enabled()) + ClearRequests(); + 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->byref + && !(il2cpp_field_get_flags(field) & FIELD_ATTRIBUTE_STATIC) && field->type->type == type; +} +} // namespace + +void ClearActionQueueRecoveryRequests() +{ ClearRequests(); } + +void InstallActionQueueRecovery() +{ + 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; + } + 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); + 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); + 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 + && !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; + } + 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() {} +void ClearActionQueueRecoveryRequests() {} +#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..92c71b7a7 --- /dev/null +++ b/mods/src/patches/parts/action_queue.h @@ -0,0 +1,107 @@ +#pragma once + +#include +#include +#include + +// Invalidate pending recovery authorization at both queue toggle transitions. +void ClearActionQueueRecoveryRequests(); + +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/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 new file mode 100644 index 000000000..26d5278a3 --- /dev/null +++ b/mods/src/patches/parts/thin_queue_protection.cc @@ -0,0 +1,277 @@ +#include "patches/action_queue_guard_policy.h" +#include + +// Port of the active v2.1.0-guffa.10 guard, not the dormant completion repair. +// Windows x64 callback ABI is validated against running metadata; current native fit is recorded separately. +#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 || 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); + 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->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 = 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; + if (!underlying || underlying->type != IL2CPP_TYPE_I4) + out = nullptr; + } + return out != nullptr; +} +} // namespace + +void InstallThinQueueProtection() +{ + 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; + 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* 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)); + 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) + && 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 d4e50a9d9..163b49e97 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -49,6 +49,8 @@ void InstallDoubleClickAssignShipHooks(); void InstallInstantWarpConfirmationHooks(); void InstallForbiddenTechConfirmationHooks(); void InstallAudioEventHooks(); +void InstallActionQueueRecovery(); +void InstallThinQueueProtection(); __int64 il2cpp_init_hook(auto original, const char* domain_name) { @@ -152,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); 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..34ae5330b --- /dev/null +++ b/tests/thin_queue_protection.cc @@ -0,0 +1,222 @@ +#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() +{ + // 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"; +}