From 23b9cbdd8cff78350a126612c66e6b841c864e34 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 7 Jun 2026 18:32:34 -0500 Subject: [PATCH 01/11] fix(queue): repair off-screen Kirshara advancement --- example_community_patch_settings.toml | 3 + mods/src/config.cc | 2 + mods/src/config.h | 1 + mods/src/defaultconfig.h | 1 + mods/src/patches/parts/action_queue_repair.cc | 628 ++++++++++++++++++ mods/src/patches/patches.cc | 2 + mods/src/prime/ActionQueueManager.h | 16 + mods/src/prime/FleetDeployedData.h | 51 +- 8 files changed, 697 insertions(+), 7 deletions(-) create mode 100644 mods/src/patches/parts/action_queue_repair.cc diff --git a/example_community_patch_settings.toml b/example_community_patch_settings.toml index fe95a9e5e..5a87e6c33 100644 --- a/example_community_patch_settings.toml +++ b/example_community_patch_settings.toml @@ -77,6 +77,9 @@ hotkeys_extended = true # If you have the Kir'Shara artifact, should the queue be enabled by default? queue_enabled = true +# Experimental repair for off-screen Kir'Shara queue advancement stalls. +kirshara_queue_repair = false + # If you prefer to use Scopely's hotkeys set this to true use_scopely_hotkeys = false diff --git a/mods/src/config.cc b/mods/src/config.cc index c25f0526c..90b434f4b 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -526,6 +526,8 @@ void Config::Load() this->use_scopely_hotkeys = get_config_or_default(config, parsed, "control", "use_scopely_hotkeys", DCC::use_scopely_hotkeys, write_config); this->select_timer = get_config_or_default(config, parsed, "control", "select_timer", DCC::select_timer, write_config); this->enable_experimental = get_config_or_default(config, parsed, "control", "enable_experimental", DCC::enable_experimental, write_config); + this->kirshara_queue_repair = + get_config_or_default(config, parsed, "control", "kirshara_queue_repair", DCC::kirshara_queue_repair, write_config); spdlog::debug(""); diff --git a/mods/src/config.h b/mods/src/config.h index 208e7139e..8e6b0a730 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -149,6 +149,7 @@ class Config final bool use_scopely_hotkeys; bool use_presets_as_default; bool enable_experimental; + bool kirshara_queue_repair; float default_system_zoom; float system_zoom_preset_1; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index f8cd6ac03..4539df6bc 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -20,6 +20,7 @@ namespace Control constexpr bool hotkeys_extended = true; constexpr bool use_scopely_hotkeys = false; constexpr bool queue_enabled = true; + constexpr bool kirshara_queue_repair = false; constexpr auto select_timer = 500; } // namespace Control diff --git a/mods/src/patches/parts/action_queue_repair.cc b/mods/src/patches/parts/action_queue_repair.cc new file mode 100644 index 000000000..7590cc8ef --- /dev/null +++ b/mods/src/patches/parts/action_queue_repair.cc @@ -0,0 +1,628 @@ +/** + * @file action_queue_repair.cc + * @brief Narrow Kir'Shara queue completion repair. + * + * Repairs the observed off-screen queue stall by replaying the target-id ProcessQueue completion seam only when a + * recently planned course target is still present in the same fleet's live queue. The repair intentionally installs + * only the completion hooks in this file. + */ +#include "config.h" +#include "errormsg.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace +{ +struct CourseTargetDeployedFleetId { + bool present = false; + std::int64_t value = 0; +}; + +struct CourseTargetCompletionCandidate { + std::int64_t target_id = 0; + std::int64_t updated_at_ms = 0; + bool consumed = false; +}; + +struct PlayerOnlyBattleStartCandidate { + std::int64_t deployed_id = 0; + std::int64_t updated_at_ms = 0; +}; + +struct FleetDeployedDataSnapshot { + bool present = false; + std::int64_t id = 0; + int state = -1; + int previous_state = -1; + int type = -1; + bool destroyed = false; + bool currently_battling = false; + bool player_combat_start = false; +}; + +struct CourseTargetQueueGuard { + bool relevant = false; + int queue_count = -1; + int target_index = -1; + void* instance = nullptr; +}; + +struct CourseTargetCompletionSynthesis { + bool should_synthesize = false; + std::int64_t fleet_key = 0; + std::int64_t deployed_id = 0; + std::int64_t target_id = 0; + std::int64_t course_age_ms = 0; + std::int64_t battle_age_ms = 0; + int queue_count = -1; + int target_index = -1; +}; + +using ProcessQueueTargetMethod = void(ActionQueueManager*, std::int64_t, bool); + +bool RepairEnabled() +{ + const auto& config = Config::Get(); + return config.queue_enabled && config.kirshara_queue_repair; +} + +std::int64_t NowMs() +{ + return std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +std::int64_t AgeMs(std::int64_t now, std::int64_t previous) +{ return now >= previous ? now - previous : previous - now; } + +std::mutex& CourseTargetCompletionMutex() +{ + static std::mutex mutex; + return mutex; +} + +std::unordered_map& CourseTargetCompletionTargets() +{ + static std::unordered_map targets; + return targets; +} + +std::unordered_map& CourseTargetCompletionBattleStarts() +{ + static std::unordered_map starts; + return starts; +} + +FleetDeployedDataSnapshot SnapshotFleetDeployedData(FleetDeployedData* deployed_data) +{ + FleetDeployedDataSnapshot snapshot; + if (!deployed_data) { + return snapshot; + } + + snapshot.present = true; + snapshot.id = deployed_data->ID; + snapshot.state = deployed_data->CurrentState; + snapshot.previous_state = deployed_data->PreviousState; + snapshot.type = static_cast(deployed_data->FleetType); + snapshot.destroyed = deployed_data->IsDestroyed; + snapshot.currently_battling = deployed_data->CurrentlyBattling; + snapshot.player_combat_start = snapshot.type == static_cast(DeployedFleetType::Player) && snapshot.state == 6 + && (snapshot.previous_state == 0 || snapshot.previous_state == 1) + && snapshot.currently_battling && !snapshot.destroyed; + return snapshot; +} + +CourseTargetDeployedFleetId ReadCourseDataTargetDeployedFleetId(void* course_data) +{ + if (!course_data) { + return {}; + } + + static auto class_helper = + il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "CourseData"); + if (!class_helper.isValidHelper()) { + return {}; + } + + static auto field_offset = []() -> ptrdiff_t { + auto field = class_helper.GetField("k__BackingField"); + return field.isValidHelper() ? field.offset() : -1; + }(); + if (field_offset < 0) { + return {}; + } + + auto* nullable = reinterpret_cast(course_data) + field_offset; + const auto present = *reinterpret_cast(nullable); + const auto value = present ? *reinterpret_cast(nullable + 8) : 0; + return {present, value}; +} + +FleetPlayerData* FindPlayerFleetDataById(std::int64_t fleet_id) +{ + auto* fleets_manager = FleetsManager::Instance(); + if (!fleets_manager || fleet_id == 0) { + return nullptr; + } + + for (int index = 0; index < 8; ++index) { + auto* fleet = fleets_manager->GetFleetPlayerData(index); + if (fleet && static_cast(fleet->Id) == fleet_id) { + return fleet; + } + } + + return nullptr; +} + +FleetPlayerData* FindPlayerFleetDataByCourseFleetId(std::int64_t fleet_id) +{ + auto* fleets_manager = FleetsManager::Instance(); + if (!fleets_manager || fleet_id == 0) { + return nullptr; + } + + const auto low_fleet_id = static_cast(static_cast(fleet_id)); + auto* low_match = static_cast(nullptr); + for (int index = 0; index < 8; ++index) { + auto* fleet = fleets_manager->GetFleetPlayerData(index); + if (!fleet) { + continue; + } + + if (static_cast(fleet->Id) == fleet_id) { + return fleet; + } + + if (static_cast(fleet->Id) == low_fleet_id) { + low_match = fleet; + } + } + + return low_match; +} + +std::uint64_t ActionQueueInstanceFleetId(void* action_queue_instance) +{ + if (!action_queue_instance) { + return 0; + } + + static auto class_helper = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueInstance"); + if (!class_helper.isValidHelper()) { + return 0; + } + + static auto field_offset = []() -> ptrdiff_t { + auto field = class_helper.GetField("k__BackingField"); + return field.isValidHelper() ? field.offset() : -1; + }(); + if (field_offset < 0) { + return 0; + } + + return *reinterpret_cast(reinterpret_cast(action_queue_instance) + field_offset); +} + +IList* ActionQueueInstanceList(void* action_queue_instance) +{ + if (!action_queue_instance) { + return nullptr; + } + + static auto class_helper = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueInstance"); + if (!class_helper.isValidHelper()) { + return nullptr; + } + + static auto field_offset = []() -> ptrdiff_t { + auto field = class_helper.GetField("_actionQueue"); + return field.isValidHelper() ? field.offset() : -1; + }(); + if (field_offset < 0) { + return nullptr; + } + + return *reinterpret_cast(reinterpret_cast(action_queue_instance) + field_offset); +} + +std::int64_t QueueableActionFleetId(Il2CppObject* queueable_action) +{ + if (!queueable_action) { + return 0; + } + + static auto class_helper = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "QueueableAction"); + if (!class_helper.isValidHelper()) { + return 0; + } + + static auto field_offset = []() -> ptrdiff_t { + auto field = class_helper.GetField("k__BackingField"); + return field.isValidHelper() ? field.offset() : -1; + }(); + if (field_offset < 0) { + return 0; + } + + return *reinterpret_cast(reinterpret_cast(queueable_action) + field_offset); +} + +int FindActionQueueItemIndex(IList* list, std::int64_t target_id) +{ + if (!list) { + return -1; + } + + const auto item_count = std::min(list->Count, 32); + for (int index = 0; index < item_count; ++index) { + if (QueueableActionFleetId(list->Get(index)) == target_id) { + return index; + } + } + + return -1; +} + +bool TryGetNativeActionQueueInstance(ActionQueueManager* manager, FleetPlayerData* fleet, void** action_queue_instance) +{ + if (action_queue_instance) { + *action_queue_instance = nullptr; + } + if (!manager || !fleet) { + return false; + } + + static auto class_helper = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueManager"); + if (!class_helper.isValidHelper()) { + return false; + } + + static const MethodInfo* try_get_action_queue_instance = + class_helper.GetMethodInfoSpecial("TryGetActionQueueInstance", [](int param_count, const Il2CppType** params) { + return param_count == 2 && params && params[0] && params[1] && params[1]->byref; + }); + if (!try_get_action_queue_instance) { + return false; + } + + void* instance = nullptr; + void* args[2] = {fleet, &instance}; + Il2CppException* exception = nullptr; + auto* result = il2cpp_runtime_invoke(try_get_action_queue_instance, manager, args, &exception); + if (exception || !result) { + return false; + } + + const auto success = *reinterpret_cast(il2cpp_object_unbox(result)); + if (success && action_queue_instance) { + *action_queue_instance = instance; + } + return success; +} + +void* FindActionQueueInstanceForFleet(ActionQueueManager* manager, FleetPlayerData* fleet) +{ + if (!manager || !fleet) { + return nullptr; + } + + void* native_action_queue_instance = nullptr; + if (TryGetNativeActionQueueInstance(manager, fleet, &native_action_queue_instance) && native_action_queue_instance) { + return native_action_queue_instance; + } + + static auto class_helper = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueManager"); + if (!class_helper.isValidHelper()) { + return nullptr; + } + + static auto battle_queue_field_offset = []() -> ptrdiff_t { + auto field = class_helper.GetField("_battleQueue"); + return field.isValidHelper() ? field.offset() : -1; + }(); + if (battle_queue_field_offset < 0) { + return nullptr; + } + + auto* battle_queue = *reinterpret_cast(reinterpret_cast(manager) + battle_queue_field_offset); + if (!battle_queue) { + return nullptr; + } + + auto* sized_array = reinterpret_cast(battle_queue); + for (size_t index = 0; index < static_cast(sized_array->max_length); ++index) { + auto* action_queue_instance = il2cpp_get_array_element(battle_queue, index); + if (ActionQueueInstanceFleetId(action_queue_instance) == fleet->Id) { + return action_queue_instance; + } + } + + return nullptr; +} + +CourseTargetQueueGuard CheckCourseTargetStillQueued(ActionQueueManager* manager, std::int64_t deployed_id, + std::int64_t target_id) +{ + CourseTargetQueueGuard guard; + if (!manager || target_id == 0) { + return guard; + } + + auto* fleet = FindPlayerFleetDataById(deployed_id); + if (!fleet || !manager->IsFleetInQueue(fleet)) { + return guard; + } + + guard.queue_count = manager->GetActionQueueCount(fleet); + if (guard.queue_count <= 0) { + return guard; + } + + guard.instance = FindActionQueueInstanceForFleet(manager, fleet); + auto* list = ActionQueueInstanceList(guard.instance); + guard.target_index = FindActionQueueItemIndex(list, target_id); + guard.relevant = guard.target_index >= 0; + return guard; +} + +void LatchCourseTargetCompletionTarget(std::int64_t fleet_id, std::int64_t target_id) +{ + if (!RepairEnabled() || target_id == 0) { + return; + } + + auto* fleet = FindPlayerFleetDataByCourseFleetId(fleet_id); + if (!fleet) { + return; + } + + const auto key = static_cast(fleet->Id); + { + std::lock_guard lk(CourseTargetCompletionMutex()); + auto& state = CourseTargetCompletionTargets()[key]; + state.target_id = target_id; + state.updated_at_ms = NowMs(); + state.consumed = false; + } +} + +void LatchPlayerOnlyBattleStart(void* fleets) +{ + if (!RepairEnabled() || !fleets) { + return; + } + + auto* list = static_cast(fleets); + if (list->Count != 1) { + return; + } + + auto deployed = SnapshotFleetDeployedData(reinterpret_cast(list->Get(0))); + if (!deployed.player_combat_start) { + return; + } + + const auto key = deployed.id; + { + std::lock_guard lk(CourseTargetCompletionMutex()); + CourseTargetCompletionBattleStarts()[key] = { + .deployed_id = deployed.id, + .updated_at_ms = NowMs(), + }; + } +} + +bool MarkCourseTargetCompletionConsumed(std::int64_t fleet_key, const CourseTargetCompletionCandidate& expected) +{ + std::lock_guard lk(CourseTargetCompletionMutex()); + auto& targets = CourseTargetCompletionTargets(); + const auto target = targets.find(fleet_key); + if (target == targets.end() || target->second.target_id != expected.target_id + || target->second.updated_at_ms != expected.updated_at_ms) { + return false; + } + + target->second.consumed = true; + return true; +} + +CourseTargetCompletionSynthesis TakeCourseTargetCompletionSynthesis(ActionQueueManager* manager, + const FleetDeployedDataSnapshot& deployed) +{ + if (!RepairEnabled() || !deployed.player_combat_start) { + return {}; + } + + constexpr std::int64_t kBattleStartWindowMs = 5000; + constexpr std::int64_t kCourseTargetWindowMs = 300000; + + const auto now = NowMs(); + const auto key = deployed.id; + + CourseTargetCompletionCandidate target_state; + PlayerOnlyBattleStartCandidate start_state; + { + std::lock_guard lk(CourseTargetCompletionMutex()); + auto& targets = CourseTargetCompletionTargets(); + auto& starts = CourseTargetCompletionBattleStarts(); + const auto target = targets.find(key); + const auto start = starts.find(key); + if (target == targets.end() || start == starts.end() || target->second.consumed || target->second.target_id == 0) { + return {}; + } + target_state = target->second; + start_state = start->second; + } + + const auto battle_age = AgeMs(now, start_state.updated_at_ms); + const auto course_age = AgeMs(now, target_state.updated_at_ms); + if (battle_age > kBattleStartWindowMs || course_age > kCourseTargetWindowMs) { + return {}; + } + + const auto guard = CheckCourseTargetStillQueued(manager, key, target_state.target_id); + if (!guard.relevant) { + MarkCourseTargetCompletionConsumed(key, target_state); + return {}; + } + + MarkCourseTargetCompletionConsumed(key, target_state); + return { + .should_synthesize = true, + .fleet_key = key, + .deployed_id = deployed.id, + .target_id = target_state.target_id, + .course_age_ms = course_age, + .battle_age_ms = battle_age, + .queue_count = guard.queue_count, + .target_index = guard.target_index, + }; +} + +bool IsProcessQueueTargetSignature(int param_count, const Il2CppType** params) +{ + return param_count == 2 && params && params[0] && params[1] && params[0]->type == IL2CPP_TYPE_I8 + && params[1]->type == IL2CPP_TYPE_BOOLEAN; +} + +std::string TypeName(const Il2CppType* type) +{ + if (!type) { + return {}; + } + + auto* raw_name = il2cpp_type_get_name(type); + if (!raw_name) { + return {}; + } + + std::string name = raw_name; + il2cpp_free(raw_name); + return name; +} + +bool IsProcessQueueDeployedSignature(int param_count, const Il2CppType** params) +{ + return param_count == 2 && params && params[0] && params[1] && params[1]->type == IL2CPP_TYPE_BOOLEAN + && TypeName(params[0]).find("FleetDeployedData") != std::string::npos; +} + +bool IsOnSetCourseResponseSignature(int param_count, const Il2CppType** params) +{ + return param_count == 4 && params && params[0] && params[1] && params[2] && params[0]->type == IL2CPP_TYPE_I8 + && params[1]->type == IL2CPP_TYPE_BOOLEAN && params[2]->type == IL2CPP_TYPE_BOOLEAN; +} + +bool IsSingleParameterSignature(int param_count, const Il2CppType** params) +{ return param_count == 1 && params && params[0]; } + +ProcessQueueTargetMethod* ResolveProcessQueueTargetForCompletion() +{ + static auto actionqueue_manager = + il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueManager"); + if (!actionqueue_manager.isValidHelper()) { + return nullptr; + } + + static auto method = + actionqueue_manager.GetMethodSpecial("ProcessQueue", IsProcessQueueTargetSignature); + return method; +} + +void ActionQueueManager_ProcessQueueDeployed(auto original, ActionQueueManager* _this, FleetDeployedData* deployed_data, + bool can_select_new_target) +{ + const auto deployed = SnapshotFleetDeployedData(deployed_data); + + original(_this, deployed_data, can_select_new_target); + + const auto synthesis = TakeCourseTargetCompletionSynthesis(_this, deployed); + if (!synthesis.should_synthesize) { + return; + } + + auto* process_target = ResolveProcessQueueTargetForCompletion(); + if (!process_target) { + ErrorMsg::MissingMethod("ActionQueueManager", "ProcessQueue(Int64, bool)"); + return; + } + + spdlog::info("[KirsharaQueueRepair] completing queued target fleet={} target={} count={} index={} course_age_ms={} " + "battle_age_ms={}", + synthesis.deployed_id, synthesis.target_id, synthesis.queue_count, synthesis.target_index, + synthesis.course_age_ms, synthesis.battle_age_ms); + process_target(_this, synthesis.target_id, can_select_new_target); +} + +void ActionQueueManager_OnSetCourseResponseEventHandler(auto original, ActionQueueManager* _this, std::int64_t fleet_id, + bool request_successful, bool is_recall, + void* planned_course_data) +{ + const auto course_target = ReadCourseDataTargetDeployedFleetId(planned_course_data); + if (request_successful && !is_recall && course_target.present) { + LatchCourseTargetCompletionTarget(fleet_id, course_target.value); + } + + original(_this, fleet_id, request_successful, is_recall, planned_course_data); +} + +void ActionQueueManager_OnFleetStateChangeEventHandler(auto original, ActionQueueManager* _this, void* fleets) +{ + LatchPlayerOnlyBattleStart(fleets); + original(_this, fleets); +} +} // namespace + +void InstallActionQueueRepairHooks() +{ + if (!RepairEnabled()) { + return; + } + + static auto actionqueue_manager = + il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueManager"); + if (!actionqueue_manager.isValidHelper()) { + ErrorMsg::MissingHelper("ActionQueue", "ActionQueueManager"); + return; + } + + auto* ptr_process_deployed = actionqueue_manager.GetMethodSpecial("ProcessQueue", IsProcessQueueDeployedSignature); + if (ptr_process_deployed) { + SPUD_STATIC_DETOUR(ptr_process_deployed, ActionQueueManager_ProcessQueueDeployed); + } else { + ErrorMsg::MissingMethod("ActionQueueManager", "ProcessQueue(FleetDeployedData, bool)"); + } + + if (auto* ptr = + actionqueue_manager.GetMethodSpecial("OnSetCourseResponseEventHandler", IsOnSetCourseResponseSignature); + ptr) { + SPUD_STATIC_DETOUR(ptr, ActionQueueManager_OnSetCourseResponseEventHandler); + } else { + ErrorMsg::MissingMethod("ActionQueueManager", "OnSetCourseResponseEventHandler"); + } + + if (auto* ptr = actionqueue_manager.GetMethodSpecial("OnFleetStateChangeEventHandler", IsSingleParameterSignature); + ptr) { + SPUD_STATIC_DETOUR(ptr, ActionQueueManager_OnFleetStateChangeEventHandler); + } else { + ErrorMsg::MissingMethod("ActionQueueManager", "OnFleetStateChangeEventHandler"); + } + + spdlog::info("[KirsharaQueueRepair] installed off-screen queue completion repair"); +} diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index 948b782e8..470ce60b1 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -37,6 +37,7 @@ void InstallTempCrashFixes(); void InstallSyncPatches(); void InstallObjectTrackers(); void InstallLoadingScreenBgHooks(); +void InstallActionQueueRepairHooks(); __int64 il2cpp_init_hook(auto original, const char* domain_name) { @@ -120,6 +121,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) {"SyncPatches", {InstallSyncPatches, &cfg.installSyncPatches}}, {"ObjectTracker", {InstallObjectTrackers, &cfg.installObjectTracker}}, {"LoadingScreenBgHooks", {InstallLoadingScreenBgHooks, &cfg.installLoadingScreenBgHooks}}, + {"KirsharaQueueRepair", {InstallActionQueueRepairHooks, &cfg.kirshara_queue_repair}}, }; printf("il2cpp_init_hook(%s)\n", domain_name); diff --git a/mods/src/prime/ActionQueueManager.h b/mods/src/prime/ActionQueueManager.h index c9a06d675..f05745321 100644 --- a/mods/src/prime/ActionQueueManager.h +++ b/mods/src/prime/ActionQueueManager.h @@ -39,6 +39,22 @@ struct ActionQueueManager : MonoSingleton { return false; } + int GetActionQueueCount(FleetPlayerData* playerData) + { + static auto GetActionQueueCountMethod = + get_class_helper().GetMethod("GetActionQueueCount"); + static auto GetActionQueueCountWarn = true; + + if (GetActionQueueCountMethod) { + return GetActionQueueCountMethod(this, playerData); + } else if (GetActionQueueCountWarn) { + GetActionQueueCountWarn = false; + ErrorMsg::MissingMethod("ActionQueueManager", "GetActionQueueCount"); + } + + return -1; + } + bool IsFleetInQueue(FleetPlayerData* playerData) { static auto IsFleetInQueueMethod = diff --git a/mods/src/prime/FleetDeployedData.h b/mods/src/prime/FleetDeployedData.h index 0f7bf2e13..f296b6c6f 100644 --- a/mods/src/prime/FleetDeployedData.h +++ b/mods/src/prime/FleetDeployedData.h @@ -1,8 +1,10 @@ #pragma once -#include #include "HullSpec.h" +#include +#include + enum class DeployedFleetType { Nonexistent, Player, @@ -10,13 +12,18 @@ enum class DeployedFleetType { NpcInstantiated, Sentinel, Alliance, + Challenge, }; struct FleetDeployedData { public: - __declspec(property(get = __get_ID)) long ID; + __declspec(property(get = __get_CurrentlyBattling)) bool CurrentlyBattling; + __declspec(property(get = __get_CurrentState)) int CurrentState; + __declspec(property(get = __get_ID)) std::int64_t ID; + __declspec(property(get = __get_IsDestroyed)) bool IsDestroyed; __declspec(property(get = __get_Hull)) HullSpec* Hull; __declspec(property(get = __get_FleetType)) DeployedFleetType FleetType; + __declspec(property(get = __get_PreviousState)) int PreviousState; private: static IL2CppClassHelper& get_class_helper() @@ -27,10 +34,32 @@ struct FleetDeployedData { } public: - long __get_ID() + bool __get_CurrentlyBattling() + { + static auto prop = get_class_helper().GetProperty("CurrentlyBattling"); + auto* value = prop.Get(this); + return value ? *value : false; + } + + int __get_CurrentState() { - static auto field = get_class_helper().GetProperty("ID"); - return *field.Get(this); + static auto prop = get_class_helper().GetProperty("CurrentState"); + auto* value = prop.Get(this); + return value ? *value : -1; + } + + std::int64_t __get_ID() + { + static auto prop = get_class_helper().GetProperty("ID"); + auto* value = prop.Get(this); + return value ? *value : 0; + } + + bool __get_IsDestroyed() + { + static auto prop = get_class_helper().GetProperty("IsDestroyed"); + auto* value = prop.Get(this); + return value ? *value : false; } HullSpec* __get_Hull() @@ -41,7 +70,15 @@ struct FleetDeployedData { DeployedFleetType __get_FleetType() { - static auto field = get_class_helper().GetProperty("FleetType"); - return *field.Get(this); + static auto prop = get_class_helper().GetProperty("FleetType"); + auto* value = prop.Get(this); + return value ? *value : DeployedFleetType::Nonexistent; + } + + int __get_PreviousState() + { + static auto prop = get_class_helper().GetProperty("PreviousState"); + auto* value = prop.Get(this); + return value ? *value : -1; } }; From a5abe592159d92021c8980dbfe1c9bfa2d463c95 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Tue, 15 Sep 2026 23:32:29 -0500 Subject: [PATCH 02/11] Add opt-in bounded native action queue tracing --- mods/src/patches/parts/action_queue.cc | 220 +++++++++++++++++++++++++ mods/src/patches/patches.cc | 3 + 2 files changed, 223 insertions(+) create mode 100644 mods/src/patches/parts/action_queue.cc diff --git a/mods/src/patches/parts/action_queue.cc b/mods/src/patches/parts/action_queue.cc new file mode 100644 index 000000000..d6aceca85 --- /dev/null +++ b/mods/src/patches/parts/action_queue.cc @@ -0,0 +1,220 @@ +// Temporary observation-only study. Enable with STFC_QUEUE_TRACE=1 in releasedbg. +#if defined(_WIN32) && defined(_M_X64) && defined(_MODDBG) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using Clock = std::chrono::steady_clock; +Clock::time_point deadline; +std::atomic_uint budget{1024}; +std::atomic_bool ready{false}; + +bool Active() +{ return ready.load() && budget.load() != 0 && Clock::now() < deadline; } +bool Reserve() +{ + auto remaining = budget.load(); + while (remaining && !budget.compare_exchange_weak(remaining, remaining - 1)) {} + return remaining != 0; +} +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; +} +struct Snapshot { + std::int64_t fleet{}, last{}, pending{}; + float attempt{}; + int count{-1}, state{-1}; + bool engaging{}; + bool operator==(const Snapshot&) const = default; +}; +Snapshot Capture(Il2CppObject* queue, Il2CppObject* deployed = nullptr) +{ + Snapshot s; + if (!queue) + return s; + s.fleet = Read(queue, 0x30); + s.last = Read(queue, 0x18); + s.pending = Read(queue, 0x20); + s.attempt = Read(queue, 0x14); + s.engaging = Read(queue, 0x10); + if (deployed) + s.state = Read(deployed, 0x80); + // Inspect List's count only if the actual object exposes the expected field. + if (auto* list = Read(queue, 0x28)) { + auto* field = il2cpp_class_get_field_from_name(il2cpp_object_get_class(list), "_size"); + if (field && field->type && field->type->type == IL2CPP_TYPE_I4) + il2cpp_field_get_value(list, field, &s.count); + } + return s; +} +void Log(const char* event, const Snapshot& s, int result = -1) +{ + if (Reserve()) + spdlog::info("[QueueTrace] {} fleet={} count={} engaging={} last={} pending={} attempt={} state={} result={}", + event, s.fleet, s.count, s.engaging, s.last, s.pending, s.attempt, s.state, result); +} +bool Changed(const Snapshot& s) +{ + struct Record { + Snapshot snapshot; + Clock::time_point logged{}; + }; + static std::array records{}; + static std::mutex mutex; + std::lock_guard lock(mutex); + auto* record = &records.front(); + for (auto& entry : records) { + if (entry.snapshot.fleet == s.fleet) { + record = &entry; + break; + } + if (entry.logged < record->logged) + record = &entry; + } + const auto now = Clock::now(); + if (record->snapshot == s && now - record->logged < std::chrono::seconds(30)) + return false; + *record = {s, now}; + return true; +} +void Stall(auto original, Il2CppObject* manager, Il2CppObject* queue, Il2CppObject* player, Il2CppObject* deployed) +{ + try { + if (Active()) { + const auto snapshot = Capture(queue, deployed); + if (Changed(snapshot)) + Log("watchdog-before", snapshot); + } + } catch (...) { + } + original(manager, queue, player, deployed); +} +int Engage(auto original, Il2CppObject* manager, Il2CppObject* player, Il2CppObject* queue) +{ + bool trace = false; + Snapshot before; + try { + trace = Active(); + if (trace) + before = Capture(queue); + } catch (...) { + trace = false; + } + const auto result = original(manager, player, queue); + // Retain primitives only across the native call; do not reread possibly-released objects. + try { + if (trace) + Log("engage-result", before, result); + } catch (...) { + } + return result; +} +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) +{ + try { + if (Active() && Reserve()) { + std::int64_t target{}; + if (args.target) { + const auto* name = il2cpp_class_get_name(il2cpp_object_get_class(args.target)); + if (name && std::strcmp(name, "Int64") == 0) + std::memcpy(&target, il2cpp_object_unbox(args.target), sizeof(target)); + } + spdlog::info("[QueueTrace] course-response fleet={} target={} success={} recall={}", args.fleet, target, + args.success, args.recall); + } + } catch (...) { + } + original(manager, args); +} +bool Field(IL2CppClassHelper& cls, const char* name, std::ptrdiff_t offset) +{ + if (!cls.isValidHelper()) + return false; + auto field = cls.GetField(name); + return field.isValidHelper() && field.offset() == offset; +} +bool Method(void* method, std::uintptr_t rva, unsigned extent, const std::array& bytes) +{ + const auto base = reinterpret_cast(GetModuleHandleW(L"GameAssembly.dll")); + if (!base || reinterpret_cast(method) != base + rva) + return false; + DWORD64 image{}; + auto* entry = RtlLookupFunctionEntry(base + rva, &image, nullptr); + return entry && image == base && entry->BeginAddress == rva && entry->EndAddress - entry->BeginAddress == extent + && std::memcmp(method, bytes.data(), bytes.size()) == 0; +} +} // namespace + +void InstallActionQueueTrace() +{ + char enabled[8]{}; + if (GetEnvironmentVariableA("STFC_QUEUE_TRACE", enabled, sizeof(enabled)) != 1 || enabled[0] != '1') + return; + auto manager = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueManager"); + auto queue = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueInstance"); + auto deployed = + il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "FleetDeployedData"); + if (!manager.isValidHelper()) { + spdlog::warn("[QueueTrace] disabled: queue manager unavailable"); + return; + } + auto* stall = manager.GetMethodSpecial("HandleStall", [](int n, const Il2CppType**) { return n == 3; }); + auto* engage = + manager.GetMethodSpecial("TryPlanPathAndEngageTarget", [](int n, const Il2CppType**) { return n == 2; }); + auto* courseInfo = manager.GetMethodInfoSpecial("OnSetCourseResponseEventHandler", [](int n, const Il2CppType** p) { + return n == 1 && p && p[0] && !p[0]->byref && p[0]->type == IL2CPP_TYPE_VALUETYPE; + }); + auto* course = courseInfo ? reinterpret_cast(courseInfo->methodPointer) : nullptr; + auto* eventClass = courseInfo ? il2cpp_class_from_type(courseInfo->parameters[0]) : nullptr; + IL2CppClassHelper event(eventClass); + std::uint32_t alignment{}; + const bool valid = + eventClass && il2cpp_class_value_size(eventClass, &alignment) == sizeof(CourseResponse) + && Field(event, "k__BackingField", 0x10) && Field(event, "k__BackingField", 0x18) + && Field(event, "k__BackingField", 0x19) && Field(event, "k__BackingField", 0x20) + && Method(course, 0x110d070, 514, {0x48, 0x89, 0x5c, 0x24, 0x18, 0x57, 0x48, 0x83, 0xec, 0x20, 0x80, 0x3d, + 0x8e, 0x06, 0xb1, 0x04, 0x00, 0x48, 0x8b, 0xfa, 0x48, 0x8b, 0xd9, 0x75}) + && Field(queue, "IsEngaging", 0x10) && Field(queue, "LastEngageAttemptTime", 0x14) + && Field(queue, "LastEngagedTargetId", 0x18) && Field(queue, "PendingEngageTargetId", 0x20) + && Field(queue, "_actionQueue", 0x28) && Field(queue, "k__BackingField", 0x30) + && Field(deployed, "_stateContainer", 0x80) + && Method(stall, 0x110df70, 341, {0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, + 0x74, 0x24, 0x18, 0x57, 0x48, 0x83, 0xec, 0x40, 0x0f, 0x29, 0x74, 0x24}) + && Method(engage, 0x1109f60, 2340, {0x4c, 0x89, 0x44, 0x24, 0x18, 0x48, 0x89, 0x54, 0x24, 0x10, 0x48, 0x89, + 0x4c, 0x24, 0x08, 0x53, 0x56, 0x57, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56}); + if (!valid) { + spdlog::warn("[QueueTrace] disabled: native build/layout does not match validated client261"); + return; + } + const bool first = SPUD_STATIC_DETOUR(stall, Stall) != nullptr; + const bool second = SPUD_STATIC_DETOUR(engage, Engage) != nullptr; + const bool third = SPUD_STATIC_DETOUR(course, Course) != nullptr; + deadline = Clock::now() + std::chrono::minutes(30); + ready.store(first && second && third); + spdlog::info("[QueueTrace] observation-only ready={} budget=1024 window=30min; result 0=success 1=skip 2=stop", + ready.load()); +} +#else +void InstallActionQueueTrace() {} +#endif diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index 9579b9692..742961bbc 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -48,6 +48,7 @@ void InstallDoubleClickAssignShipHooks(); void InstallInstantWarpConfirmationHooks(); void InstallForbiddenTechConfirmationHooks(); void InstallAudioEventHooks(); +void InstallActionQueueTrace(); __int64 il2cpp_init_hook(auto original, const char* domain_name) { @@ -97,6 +98,8 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) spdlog::info(" Cfg: {}", File::Config()); spdlog::info(" Var: {}", File::Vars()); spdlog::info(" BL: {}", File::Battles()); + InstallActionQueueTrace(); + spdlog::info(""); #if VERSION_PATCH From 2a0d359cfd0856aeafe78f57c34066af367360c6 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Tue, 15 Sep 2026 23:35:27 -0500 Subject: [PATCH 03/11] Install queue trace after IL2CPP initialization --- mods/src/patches/patches.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index 742961bbc..698af802d 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -98,8 +98,6 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) spdlog::info(" Cfg: {}", File::Config()); spdlog::info(" Var: {}", File::Vars()); spdlog::info(" BL: {}", File::Battles()); - InstallActionQueueTrace(); - spdlog::info(""); #if VERSION_PATCH @@ -174,6 +172,8 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) } } + InstallActionQueueTrace(); + spdlog::info(""); #if VERSION_PATCH From d61d6db07cef4afa6bf4de51b82119e00d81cb77 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Tue, 15 Sep 2026 23:50:53 -0500 Subject: [PATCH 04/11] Trace sustained idle queue states at native watchdog cadence --- mods/src/patches/parts/action_queue.cc | 33 +++++++++++++++++--------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/mods/src/patches/parts/action_queue.cc b/mods/src/patches/parts/action_queue.cc index d6aceca85..41a97a49d 100644 --- a/mods/src/patches/parts/action_queue.cc +++ b/mods/src/patches/parts/action_queue.cc @@ -61,17 +61,18 @@ Snapshot Capture(Il2CppObject* queue, Il2CppObject* deployed = nullptr) } return s; } -void Log(const char* event, const Snapshot& s, int result = -1) +void Log(const char* event, const Snapshot& s, int result = -1, std::int64_t sameSnapshotMs = -1) { if (Reserve()) - spdlog::info("[QueueTrace] {} fleet={} count={} engaging={} last={} pending={} attempt={} state={} result={}", - event, s.fleet, s.count, s.engaging, s.last, s.pending, s.attempt, s.state, result); + spdlog::info("[QueueTrace] {} fleet={} count={} engaging={} last={} pending={} attempt={} state={} result={} " + "same_snapshot_observed_ms={}", + event, s.fleet, s.count, s.engaging, s.last, s.pending, s.attempt, s.state, result, sameSnapshotMs); } -bool Changed(const Snapshot& s) +bool Changed(const Snapshot& s, std::int64_t& sameSnapshotMs) { struct Record { Snapshot snapshot; - Clock::time_point logged{}; + Clock::time_point logged{}, firstObserved{}; }; static std::array records{}; static std::mutex mutex; @@ -85,19 +86,29 @@ bool Changed(const Snapshot& s) if (entry.logged < record->logged) record = &entry; } - const auto now = Clock::now(); - if (record->snapshot == s && now - record->logged < std::chrono::seconds(30)) + const auto now = Clock::now(); + const bool same = record->logged != Clock::time_point{} && record->snapshot == s; + if (!same) + record->firstObserved = now; + // This is equality at native watchdog samples, not proof that nothing changed between them. + sameSnapshotMs = std::chrono::duration_cast(now - record->firstObserved).count(); + // Idle nonempty queues deserve closer observation even when IsEngaging blocks the native watchdog. + // Reuse its callbacks: no timer, polling loop, or changes to retry eligibility. + const auto interval = std::chrono::seconds(s.state == 0 && s.count != 0 ? 3 : 30); + if (same && now - record->logged < interval) return false; - *record = {s, now}; + record->snapshot = s; + record->logged = now; return true; } void Stall(auto original, Il2CppObject* manager, Il2CppObject* queue, Il2CppObject* player, Il2CppObject* deployed) { try { if (Active()) { - const auto snapshot = Capture(queue, deployed); - if (Changed(snapshot)) - Log("watchdog-before", snapshot); + const auto snapshot = Capture(queue, deployed); + std::int64_t sameSnapshotMs{}; + if (Changed(snapshot, sameSnapshotMs)) + Log("watchdog-before", snapshot, -1, sameSnapshotMs); } } catch (...) { } From 43e1848e4fd10f8fcc4f50b13938c41e2b3ee5be Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Wed, 16 Sep 2026 00:00:17 -0500 Subject: [PATCH 05/11] Trace queue target order and native retry decisions --- mods/src/patches/parts/action_queue.cc | 90 ++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 7 deletions(-) diff --git a/mods/src/patches/parts/action_queue.cc b/mods/src/patches/parts/action_queue.cc index 41a97a49d..859051c27 100644 --- a/mods/src/patches/parts/action_queue.cc +++ b/mods/src/patches/parts/action_queue.cc @@ -18,6 +18,7 @@ using Clock = std::chrono::steady_clock; Clock::time_point deadline; std::atomic_uint budget{1024}; std::atomic_bool ready{false}; +Il2CppClass* actionClass{}; bool Active() { return ready.load() && budget.load() != 0 && Clock::now() < deadline; } @@ -36,6 +37,9 @@ template T Read(const void* object, std::size_t offset) } struct Snapshot { std::int64_t fleet{}, last{}, pending{}; + std::int64_t front{}, next{}; + int retries{-1}; + bool frontKnown{}, nextKnown{}; float attempt{}; int count{-1}, state{-1}; bool engaging{}; @@ -58,15 +62,41 @@ Snapshot Capture(Il2CppObject* queue, Il2CppObject* deployed = nullptr) auto* field = il2cpp_class_get_field_from_name(il2cpp_object_get_class(list), "_size"); if (field && field->type && field->type->type == IL2CPP_TYPE_I4) il2cpp_field_get_value(list, field, &s.count); + auto* itemsField = il2cpp_class_get_field_from_name(il2cpp_object_get_class(list), "_items"); + if (s.count > 0 && itemsField && itemsField->type && itemsField->type->type == IL2CPP_TYPE_SZARRAY) { + Il2CppArray* items{}; + il2cpp_field_get_value(list, itemsField, &items); + if (items && il2cpp_array_length(items) >= static_cast(s.count) + && il2cpp_class_get_element_class(il2cpp_object_get_class(reinterpret_cast(items))) + == actionClass) { + auto* array = reinterpret_cast(items); + auto* front = static_cast(array->vector[0]); + if (front && il2cpp_object_get_class(front) == actionClass) { + s.frontKnown = true; + s.front = Read(front, 0x10); + s.retries = Read(front, 0x18); + } + if (s.count > 1) { + auto* next = static_cast(array->vector[1]); + if (next && il2cpp_object_get_class(next) == actionClass) { + s.nextKnown = true; + s.next = Read(next, 0x10); + } + } + } + } } return s; } -void Log(const char* event, const Snapshot& s, int result = -1, std::int64_t sameSnapshotMs = -1) +void Log(const char* event, const Snapshot& s, int result = -1, std::int64_t sameSnapshotMs = -1, + std::int64_t target = 0) { if (Reserve()) - spdlog::info("[QueueTrace] {} fleet={} count={} engaging={} last={} pending={} attempt={} state={} result={} " - "same_snapshot_observed_ms={}", - event, s.fleet, s.count, s.engaging, s.last, s.pending, s.attempt, s.state, result, sameSnapshotMs); + spdlog::info( + "[QueueTrace] {} fleet={} count={} engaging={} last={} pending={} attempt={} state={} result={} " + "same_snapshot_observed_ms={} front_known={} front={} retries={} next_known={} next={} decision_target={}", + event, s.fleet, s.count, s.engaging, s.last, s.pending, s.attempt, s.state, result, sameSnapshotMs, + s.frontKnown, s.front, s.retries, s.nextKnown, s.next, target); } bool Changed(const Snapshot& s, std::int64_t& sameSnapshotMs) { @@ -134,6 +164,35 @@ int Engage(auto original, Il2CppObject* manager, Il2CppObject* player, Il2CppObj } return result; } +// Capture only primitives before native code; a decision is not proof that removal occurred. +bool Retry(auto original, Il2CppObject* manager, std::int64_t target, Il2CppObject* queue) +{ + bool trace = false; + Snapshot before; + try { + trace = Active(); + if (trace) + before = Capture(queue); + } catch (...) { + trace = false; + } + const bool result = original(manager, target, queue); + try { + if (trace) + Log("retry-decision", before, result ? 1 : 0, -1, target); + } catch (...) { + } + return result; +} +void ProcessTarget(auto original, Il2CppObject* manager, std::int64_t target, bool canSelect) +{ + try { + if (Active() && Reserve()) + spdlog::info("[QueueTrace] process-target-before target={} can_select_new={}", target, canSelect); + } catch (...) { + } + original(manager, target, canSelect); +} struct CourseResponse { std::int64_t fleet; bool success, recall; @@ -183,6 +242,8 @@ void InstallActionQueueTrace() if (GetEnvironmentVariableA("STFC_QUEUE_TRACE", enabled, sizeof(enabled)) != 1 || enabled[0] != '1') return; auto manager = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueManager"); + auto action = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "QueueableAction"); + actionClass = action.get_cls(); auto queue = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueInstance"); auto deployed = il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "FleetDeployedData"); @@ -193,6 +254,13 @@ void InstallActionQueueTrace() auto* stall = manager.GetMethodSpecial("HandleStall", [](int n, const Il2CppType**) { return n == 3; }); auto* engage = manager.GetMethodSpecial("TryPlanPathAndEngageTarget", [](int n, const Il2CppType**) { return n == 2; }); + auto* retry = manager.GetMethodSpecial("ShouldRetryFailedSetCourse", [](int n, const Il2CppType** p) { + return n == 2 && p && p[0] && !p[0]->byref && p[0]->type == IL2CPP_TYPE_I8; + }); + auto* process = manager.GetMethodSpecial("ProcessQueue", [](int n, const Il2CppType** p) { + return n == 2 && p && p[0] && p[1] && !p[0]->byref && !p[1]->byref && p[0]->type == IL2CPP_TYPE_I8 + && p[1]->type == IL2CPP_TYPE_BOOLEAN; + }); auto* courseInfo = manager.GetMethodInfoSpecial("OnSetCourseResponseEventHandler", [](int n, const Il2CppType** p) { return n == 1 && p && p[0] && !p[0]->byref && p[0]->type == IL2CPP_TYPE_VALUETYPE; }); @@ -201,7 +269,12 @@ void InstallActionQueueTrace() IL2CppClassHelper event(eventClass); std::uint32_t alignment{}; const bool valid = - eventClass && il2cpp_class_value_size(eventClass, &alignment) == sizeof(CourseResponse) + Field(action, "k__BackingField", 0x10) && Field(action, "SetCourseFailRetryCount", 0x18) + && Method(retry, 0x110d280, 662, {0x48, 0x89, 0x5c, 0x24, 0x10, 0x48, 0x89, 0x6c, 0x24, 0x18, 0x57, 0x48, + 0x83, 0xec, 0x20, 0x80, 0x3d, 0x7a, 0x04, 0xb1, 0x04, 0x00, 0x49, 0x8b}) + && Method(process, 0x110cb70, 367, {0x40, 0x53, 0x57, 0x41, 0x54, 0x41, 0x56, 0x41, 0x57, 0x48, 0x83, 0xec, + 0x30, 0x80, 0x3d, 0x89, 0x0b, 0xb1, 0x04, 0x00, 0x45, 0x0f, 0xb6, 0xf0}) + && eventClass && il2cpp_class_value_size(eventClass, &alignment) == sizeof(CourseResponse) && Field(event, "k__BackingField", 0x10) && Field(event, "k__BackingField", 0x18) && Field(event, "k__BackingField", 0x19) && Field(event, "k__BackingField", 0x20) && Method(course, 0x110d070, 514, {0x48, 0x89, 0x5c, 0x24, 0x18, 0x57, 0x48, 0x83, 0xec, 0x20, 0x80, 0x3d, @@ -221,9 +294,12 @@ void InstallActionQueueTrace() const bool first = SPUD_STATIC_DETOUR(stall, Stall) != nullptr; const bool second = SPUD_STATIC_DETOUR(engage, Engage) != nullptr; const bool third = SPUD_STATIC_DETOUR(course, Course) != nullptr; + const bool fourth = SPUD_STATIC_DETOUR(retry, Retry) != nullptr; + const bool fifth = SPUD_STATIC_DETOUR(process, ProcessTarget) != nullptr; deadline = Clock::now() + std::chrono::minutes(30); - ready.store(first && second && third); - spdlog::info("[QueueTrace] observation-only ready={} budget=1024 window=30min; result 0=success 1=skip 2=stop", + ready.store(first && second && third && fourth && fifth); + spdlog::info("[QueueTrace] observation-only ready={} budget=1024 window=30min; engage result 0=success 1=skip " + "2=stop; retry result 0=no 1=yes", ready.load()); } #else From 898ba10b6f3aaba6f347ef2bf3745efb940aedfc Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Wed, 16 Sep 2026 00:18:41 -0500 Subject: [PATCH 06/11] Prototype native handoff after an outstanding target was removed --- mods/src/patches/parts/action_queue.cc | 152 ++++++++++++++++++++++--- 1 file changed, 138 insertions(+), 14 deletions(-) diff --git a/mods/src/patches/parts/action_queue.cc b/mods/src/patches/parts/action_queue.cc index 859051c27..cb2fbc186 100644 --- a/mods/src/patches/parts/action_queue.cc +++ b/mods/src/patches/parts/action_queue.cc @@ -1,4 +1,5 @@ -// Temporary observation-only study. Enable with STFC_QUEUE_TRACE=1 in releasedbg. +// Temporary opt-in recovery study; STFC_QUEUE_RECOVER=1 enables the candidate. Enable with STFC_QUEUE_TRACE=1 in +// releasedbg. #if defined(_WIN32) && defined(_M_X64) && defined(_MODDBG) #include #include @@ -19,6 +20,22 @@ Clock::time_point deadline; std::atomic_uint budget{1024}; std::atomic_bool ready{false}; Il2CppClass* actionClass{}; +Il2CppClass* queueClass{}; +bool recover{}; +struct Request { + Il2CppGCHandle queueHandle{}; + std::int64_t fleet{}, target{}; + float attempt{}; + Clock::time_point started{}; +}; +std::array requests{}; +std::mutex requestsMutex; +struct CourseContext { + std::int64_t fleet{}, target{}; + Il2CppObject* queue{}; // Borrowed only within the synchronous native Course call. + bool engagingAtEntry{}, failed{}; +}; +thread_local CourseContext* currentCourse{}; bool Active() { return ready.load() && budget.load() != 0 && Clock::now() < deadline; } @@ -88,6 +105,79 @@ Snapshot Capture(Il2CppObject* queue, Il2CppObject* deployed = nullptr) } return s; } +// Record the most recent native engagement attempt; weak handles prevent recycled object identities matching. +void Remember(Il2CppObject* queue, const Snapshot& s) +{ + std::lock_guard lock(requestsMutex); + auto* slot = &requests.front(); + for (auto& r : requests) { + if (r.fleet == s.fleet) { + slot = &r; + break; + } + if (r.started < slot->started) + slot = &r; + } + if (slot->queueHandle) + il2cpp_gchandle_free(slot->queueHandle); + *slot = {il2cpp_gchandle_new_weakref(queue, false), s.fleet, s.frontKnown ? s.front : 0, s.attempt, Clock::now()}; +} +bool ConsumeRequest(Il2CppObject* queue, const Snapshot& s, std::int64_t failedTarget) +{ + std::lock_guard lock(requestsMutex); + for (auto& r : requests) { + if (r.fleet != s.fleet) + continue; + const bool match = r.queueHandle && il2cpp_gchandle_get_target(r.queueHandle) == queue && r.target == failedTarget + && r.attempt == s.attempt && Clock::now() - r.started < std::chrono::seconds(30); + if (match) { + il2cpp_gchandle_free(r.queueHandle); + r = {}; + } + return match; + } + return false; +} +bool TargetAbsent(Il2CppObject* queue, std::int64_t target) +{ + auto* list = Read(queue, 0x28); + if (!list) + return false; + 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 false; + int count{}; + Il2CppArray* items{}; + il2cpp_field_get_value(list, size, &count); + il2cpp_field_get_value(list, storage, &items); + if (count <= 0 || count > 128 || !items || il2cpp_array_length(items) < static_cast(count) + || il2cpp_class_get_element_class(il2cpp_object_get_class(reinterpret_cast(items))) != actionClass) + return false; + auto* array = reinterpret_cast(items); + for (int i = 0; i < count; ++i) { + auto* action = static_cast(array->vector[i]); + if (!action || il2cpp_object_get_class(action) != actionClass || Read(action, 0x10) == target) + return false; + } + return true; +} +Il2CppObject* FindQueue(Il2CppObject* manager, std::int64_t fleet) +{ + 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* values = reinterpret_cast(array); + for (unsigned i = 0; i < il2cpp_array_length(array); ++i) { + auto* queue = static_cast(values->vector[i]); + if (queue && il2cpp_object_get_class(queue) == queueClass && Read(queue, 0x30) == fleet) + return queue; + } + return nullptr; +} void Log(const char* event, const Snapshot& s, int result = -1, std::int64_t sameSnapshotMs = -1, std::int64_t target = 0) { @@ -150,8 +240,11 @@ int Engage(auto original, Il2CppObject* manager, Il2CppObject* player, Il2CppObj Snapshot before; try { trace = Active(); - if (trace) + if (trace || (recover && ready.load())) { before = Capture(queue); + if (recover && ready.load()) + Remember(queue, before); + } } catch (...) { trace = false; } @@ -171,7 +264,7 @@ bool Retry(auto original, Il2CppObject* manager, std::int64_t target, Il2CppObje Snapshot before; try { trace = Active(); - if (trace) + if (trace || (recover && ready.load())) before = Capture(queue); } catch (...) { trace = false; @@ -182,6 +275,20 @@ bool Retry(auto original, Il2CppObject* manager, std::int64_t target, Il2CppObje Log("retry-decision", before, result ? 1 : 0, -1, target); } catch (...) { } + // Course has already cleared IsEngaging. Only promote a non-retry after the exact outstanding + // target disappeared. Returning true uses Course's existing native planner/eligibility path. + try { + const auto* c = currentCourse; + if (!result && recover && ready.load() && c && c->failed && c->engagingAtEntry && c->queue == queue + && c->fleet == before.fleet && c->target == target && target != 0 && before.pending == target + && !before.engaging && before.frontKnown && before.front != target && before.count > 0 + && TargetAbsent(queue, target) && ConsumeRequest(queue, before, target)) { + if (Active()) + Log("advance-removed-front", before, 1, -1, target); + return true; + } + } catch (...) { + } return result; } void ProcessTarget(auto original, Il2CppObject* manager, std::int64_t target, bool canSelect) @@ -202,19 +309,32 @@ struct CourseResponse { static_assert(sizeof(CourseResponse) == 24 && offsetof(CourseResponse, target) == 16); void Course(auto original, Il2CppObject* manager, CourseResponse args) { + CourseContext context{args.fleet}; try { - if (Active() && Reserve()) { - std::int64_t target{}; - if (args.target) { - const auto* name = il2cpp_class_get_name(il2cpp_object_get_class(args.target)); - if (name && std::strcmp(name, "Int64") == 0) - std::memcpy(&target, il2cpp_object_unbox(args.target), sizeof(target)); - } + std::int64_t target{}; + if (args.target) { + const auto* name = il2cpp_class_get_name(il2cpp_object_get_class(args.target)); + if (name && std::strcmp(name, "Int64") == 0) + std::memcpy(&target, il2cpp_object_unbox(args.target), sizeof(target)); + } + context.target = target; + context.failed = !args.success && !args.recall; + if (recover && ready.load() && context.failed && target != 0) { + context.queue = FindQueue(manager, args.fleet); + context.engagingAtEntry = context.queue && Read(context.queue, 0x10); + } + if (Active() && Reserve()) spdlog::info("[QueueTrace] course-response fleet={} target={} success={} recall={}", args.fleet, target, args.success, args.recall); - } } catch (...) { } + struct Scope { + CourseContext* previous{currentCourse}; + explicit Scope(CourseContext* value) + { currentCourse = value; } + ~Scope() + { currentCourse = previous; } + } scope(&context); original(manager, args); } bool Field(IL2CppClassHelper& cls, const char* name, std::ptrdiff_t offset) @@ -241,10 +361,13 @@ void InstallActionQueueTrace() char enabled[8]{}; if (GetEnvironmentVariableA("STFC_QUEUE_TRACE", enabled, sizeof(enabled)) != 1 || enabled[0] != '1') return; + char recovery[8]{}; + recover = GetEnvironmentVariableA("STFC_QUEUE_RECOVER", recovery, sizeof(recovery)) == 1 && recovery[0] == '1'; auto manager = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueManager"); auto action = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "QueueableAction"); actionClass = action.get_cls(); auto queue = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueInstance"); + queueClass = queue.get_cls(); auto deployed = il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "FleetDeployedData"); if (!manager.isValidHelper()) { @@ -269,7 +392,8 @@ void InstallActionQueueTrace() IL2CppClassHelper event(eventClass); std::uint32_t alignment{}; const bool valid = - Field(action, "k__BackingField", 0x10) && Field(action, "SetCourseFailRetryCount", 0x18) + Field(manager, "_battleQueue", 0x48) && Field(action, "k__BackingField", 0x10) + && Field(action, "SetCourseFailRetryCount", 0x18) && Method(retry, 0x110d280, 662, {0x48, 0x89, 0x5c, 0x24, 0x10, 0x48, 0x89, 0x6c, 0x24, 0x18, 0x57, 0x48, 0x83, 0xec, 0x20, 0x80, 0x3d, 0x7a, 0x04, 0xb1, 0x04, 0x00, 0x49, 0x8b}) && Method(process, 0x110cb70, 367, {0x40, 0x53, 0x57, 0x41, 0x54, 0x41, 0x56, 0x41, 0x57, 0x48, 0x83, 0xec, @@ -298,9 +422,9 @@ void InstallActionQueueTrace() const bool fifth = SPUD_STATIC_DETOUR(process, ProcessTarget) != nullptr; deadline = Clock::now() + std::chrono::minutes(30); ready.store(first && second && third && fourth && fifth); - spdlog::info("[QueueTrace] observation-only ready={} budget=1024 window=30min; engage result 0=success 1=skip " + spdlog::info("[QueueTrace] ready={} recovery={} budget=1024 window=30min; engage result 0=success 1=skip " "2=stop; retry result 0=no 1=yes", - ready.load()); + ready.load(), recover); } #else void InstallActionQueueTrace() {} From 793582f50355171ed4c641f43115baf8907517ef Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Wed, 16 Sep 2026 00:21:23 -0500 Subject: [PATCH 07/11] Preserve native recovery when other queues retain the failed target --- mods/src/patches/parts/action_queue.cc | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/mods/src/patches/parts/action_queue.cc b/mods/src/patches/parts/action_queue.cc index cb2fbc186..45b008692 100644 --- a/mods/src/patches/parts/action_queue.cc +++ b/mods/src/patches/parts/action_queue.cc @@ -178,6 +178,26 @@ Il2CppObject* FindQueue(Il2CppObject* manager, std::int64_t fleet) } return nullptr; } +// Preserve native cross-fleet cleanup: do not take the planner branch if any queue still holds this target. +bool AbsentFromAllQueues(Il2CppObject* manager, std::int64_t target) +{ + 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 false; + auto* values = reinterpret_cast(array); + for (unsigned i = 0; i < il2cpp_array_length(array); ++i) { + auto* queue = static_cast(values->vector[i]); + if (!queue) + continue; + if (il2cpp_object_get_class(queue) != queueClass) + return false; + const auto snapshot = Capture(queue); + if (snapshot.count < 0 || (snapshot.count > 0 && !TargetAbsent(queue, target))) + return false; + } + return true; +} void Log(const char* event, const Snapshot& s, int result = -1, std::int64_t sameSnapshotMs = -1, std::int64_t target = 0) { @@ -282,7 +302,8 @@ bool Retry(auto original, Il2CppObject* manager, std::int64_t target, Il2CppObje if (!result && recover && ready.load() && c && c->failed && c->engagingAtEntry && c->queue == queue && c->fleet == before.fleet && c->target == target && target != 0 && before.pending == target && !before.engaging && before.frontKnown && before.front != target && before.count > 0 - && TargetAbsent(queue, target) && ConsumeRequest(queue, before, target)) { + && TargetAbsent(queue, target) && AbsentFromAllQueues(manager, target) + && ConsumeRequest(queue, before, target)) { if (Active()) Log("advance-removed-front", before, 1, -1, target); return true; From 209e322e569e317b857253c0644c36191761050b Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Wed, 16 Sep 2026 00:29:14 -0500 Subject: [PATCH 08/11] Recover removed requests before their first successful course response --- mods/src/patches/parts/action_queue.cc | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/mods/src/patches/parts/action_queue.cc b/mods/src/patches/parts/action_queue.cc index 45b008692..de4d4f10e 100644 --- a/mods/src/patches/parts/action_queue.cc +++ b/mods/src/patches/parts/action_queue.cc @@ -300,10 +300,9 @@ bool Retry(auto original, Il2CppObject* manager, std::int64_t target, Il2CppObje try { const auto* c = currentCourse; if (!result && recover && ready.load() && c && c->failed && c->engagingAtEntry && c->queue == queue - && c->fleet == before.fleet && c->target == target && target != 0 && before.pending == target - && !before.engaging && before.frontKnown && before.front != target && before.count > 0 - && TargetAbsent(queue, target) && AbsentFromAllQueues(manager, target) - && ConsumeRequest(queue, before, target)) { + && c->fleet == before.fleet && c->target == target && target != 0 && !before.engaging && before.frontKnown + && before.front != target && before.count > 0 && TargetAbsent(queue, target) + && AbsentFromAllQueues(manager, target) && ConsumeRequest(queue, before, target)) { if (Active()) Log("advance-removed-front", before, 1, -1, target); return true; From 79dcffdbe6b03e3a81afb012d4bb355e23f4f061 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Wed, 16 Sep 2026 00:53:08 -0500 Subject: [PATCH 09/11] Implement opt-in Faster Queue Recovery with bounded request tracking --- .github/workflows/ci.yaml | 9 + docs/FASTER_QUEUE_RECOVERY.md | 48 +++ example_community_patch_settings_de.toml | 3 + example_community_patch_settings_en.toml | 3 + example_community_patch_settings_fr.toml | 3 + example_community_patch_settings_nl.toml | 3 + mods/src/config.cc | 2 + mods/src/config.h | 1 + mods/src/defaultconfig.h | 1 + mods/src/patches/parts/action_queue.cc | 495 ++++++++--------------- mods/src/patches/parts/action_queue.h | 104 +++++ mods/src/patches/patches.cc | 4 +- tests/action_queue.cc | 120 ++++++ tests/run-action-queue.ps1 | 12 + 14 files changed, 481 insertions(+), 327 deletions(-) create mode 100644 docs/FASTER_QUEUE_RECOVERY.md create mode 100644 mods/src/patches/parts/action_queue.h create mode 100644 tests/action_queue.cc create mode 100644 tests/run-action-queue.ps1 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c4db85207..b2b3427ff 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,6 +31,15 @@ env: XMAKE_ACTION_BUILD_CACHE: "true" jobs: + action-queue-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Test queue recovery policy and request lifecycle + run: | + clang++ -std=c++23 -Imods/src tests/action_queue.cc -o /tmp/action-queue-tests + /tmp/action-queue-tests + build-win: runs-on: windows-2025-vs2026 outputs: diff --git a/docs/FASTER_QUEUE_RECOVERY.md b/docs/FASTER_QUEUE_RECOVERY.md new file mode 100644 index 000000000..cb1470485 --- /dev/null +++ b/docs/FASTER_QUEUE_RECOVERY.md @@ -0,0 +1,48 @@ +# Faster Queue Recovery + +When a queued target disappears while a course request is outstanding, the game can remove that target before its failed response arrives. Native failure handling then attempts the removal again. Since it did not remove the front entry this time, it can leave the next target waiting for the watchdog. + +Enable the opt-in recovery at startup: + +```toml +[control] +faster_queue_recovery = true +``` + +The feature also respects `control.queue_enabled`. Support is limited to the verified Windows x64 client 261 layout. Other platforms do not install these hooks; incompatible Windows layouts log an unavailable message and retain native behavior. + +## Behavior + +The adapter records the latest engagement attempt for up to eight fleets, using weak queue identities and full 64-bit target IDs. A failed, non-recall course response may request native planning only when: + +- The response belongs to the same queue and latest target/attempt, observed within 30 seconds. +- The queue was engaging on entry to the response and native processing cleared that flag. +- A different front target remains, and the failed target is absent from every inspected queue. +- The native retry decision was false. Ordinary retries are unchanged. + +The request record is consumed once. The native planner selects and validates the next target. The mod does not force ship state, clear engagement flags, change retry counts, or remove targets. If another fleet still queues the target, the native cross-fleet removal path remains responsible. + +Queue storage is bounded and validated. Unknown layouts/content are ineligible. Expired/replaced requests release their weak handles; native session cleanup clears all records. A native attempt returning skip/stop cancels only its own record, preserving newer reentrant attempts. + +There is no watchdog hook, frame scan, timer, background worker, or per-engagement logging. Queue inspection occurs in existing callbacks; all-queue inspection runs only for a potentially eligible failed response. Response matching is not a server-issued request ID, so delayed same-target responses remain an interoperability limitation. + +## Native integration + +One detour owns each method. Installation checks metadata, exact RVA, PE unwind extent, and a 24-byte prefix before installing any hook. Windows x64 client 261 targets: + +| Method | RVA | Native extent | +| --- | --- | --- | +| TryPlanPathAndEngageTarget | `0x1109f60` | 2340 bytes | +| ShouldRetryFailedSetCourse | `0x110d280` | 662 bytes | +| OnSetCourseResponseEventHandler | `0x110d070` | 514 bytes | +| StopWatchdogAndClearAllQueues | `0x110bcc0` | 493 bytes | + +The course event is a 24-byte value type, with fleet ID at 0, success/recall at 8/9 and boxed target at 16. Metadata field offsets include the boxed object header. The native retry handler is called synchronously inside the course handler; returning true selects its existing planner branch. + +Do not install the older fork queue repair alongside this implementation without consolidating the shared course-response detour. + +## Validation + +Run `tests/run-action-queue.ps1` on Windows, or compile `tests/action_queue.cc` with a C++23 compiler and `-Imods/src`. Tests use the production policy and request store with fake weak handles. They cover one-shot recovery, reordered/still-present targets, cross-fleet rejection, stale attempts, replaced/collected queues, expiry, reentrant cancellation, bounded capacity and cleanup. They do not model game ABI or network scheduling. + +The prototype produced two observed handoffs with the next target attempted 1–2 ms later and successful responses within 388–532 ms. Those timings are observations, not a latency guarantee. The final adapter requires its own smoke test: normal queued combat; removal of the outstanding target; removal before the first successful course response; queue clear/rebuild; recall; and session restart. Group-wave behavior needs additional coverage when available. diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 6756ed409..ddccd1c0d 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -94,6 +94,9 @@ 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-Client 261). +faster_queue_recovery = false + # Auf true setzen, um die Hotkeys von Scopely zu verwenden use_scopely_hotkeys = false diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 7706e7c95..3c9f86406 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -94,6 +94,9 @@ 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 client 261 only). +faster_queue_recovery = false + # If you prefer to use Scopely's hotkeys set this to true use_scopely_hotkeys = false diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index dd8e462ac..fc18df645 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -94,6 +94,9 @@ 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 (client Windows 261 uniquement). +faster_queue_recovery = false + # Si vous préférez utiliser les raccourcis de Scopely mettez ça sur activer use_scopely_hotkeys = false diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index b0726f8a9..29ff98eaa 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -94,6 +94,9 @@ 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-client 261). +faster_queue_recovery = false + # Als je de Scopely sneltoetsen prefereerd over die van de mod, schakel dit dan aan use_scopely_hotkeys = false diff --git a/mods/src/config.cc b/mods/src/config.cc index 25086345e..e03ce9950 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -934,6 +934,8 @@ void Config::Load() this->installPinnedShipSortHooks = get_config_or_default(config, parsed, "patches", "pinnedshiphooks", DCP::pinnedshiphooks, write_config); spdlog::debug(""); + this->faster_queue_recovery = get_config_or_default(config, parsed, "control", "faster_queue_recovery", + DCC::faster_queue_recovery, write_config); this->queue_enabled = get_config_or_default(config, parsed, "control", "queue_enabled", DCC::queue_enabled, write_config); this->hotkeys_enabled = diff --git a/mods/src/config.h b/mods/src/config.h index cd9d4de6d..4a243b658 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -179,6 +179,7 @@ class Config final int select_timer; bool queue_enabled; + bool faster_queue_recovery; bool hotkeys_enabled; bool hotkeys_extended; bool use_scopely_hotkeys; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index de6bc7e27..eaf158661 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -26,6 +26,7 @@ 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 auto select_timer = 500; } // namespace Control diff --git a/mods/src/patches/parts/action_queue.cc b/mods/src/patches/parts/action_queue.cc index de4d4f10e..2ad439a3a 100644 --- a/mods/src/patches/parts/action_queue.cc +++ b/mods/src/patches/parts/action_queue.cc @@ -1,12 +1,10 @@ -// Temporary opt-in recovery study; STFC_QUEUE_RECOVER=1 enables the candidate. Enable with STFC_QUEUE_TRACE=1 in -// releasedbg. -#if defined(_WIN32) && defined(_M_X64) && defined(_MODDBG) +#include "action_queue.h" +#include + +// The callback ABI and native extents below have been verified only for client 261 on Windows x64. +#if defined(_WIN32) && defined(_M_X64) #include -#include #include -#include -#include -#include #include #include #include @@ -15,35 +13,29 @@ namespace { -using Clock = std::chrono::steady_clock; -Clock::time_point deadline; -std::atomic_uint budget{1024}; -std::atomic_bool ready{false}; -Il2CppClass* actionClass{}; -Il2CppClass* queueClass{}; -bool recover{}; -struct Request { - Il2CppGCHandle queueHandle{}; - std::int64_t fleet{}, target{}; - float attempt{}; - Clock::time_point started{}; -}; -std::array requests{}; -std::mutex requestsMutex; -struct CourseContext { - std::int64_t fleet{}, target{}; - Il2CppObject* queue{}; // Borrowed only within the synchronous native Course call. - bool engagingAtEntry{}, failed{}; +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); } }; -thread_local CourseContext* currentCourse{}; +action_queue::Requests requests; +std::mutex requestsMutex; +std::atomic_bool ready{false}; +Il2CppClass * queueClass{}, *actionClass{}, *int64Class{}; -bool Active() -{ return ready.load() && budget.load() != 0 && Clock::now() < deadline; } -bool Reserve() +bool Enabled() +{ return ready.load() && Config::Get().faster_queue_recovery && Config::Get().queue_enabled; } +void ClearRequests() { - auto remaining = budget.load(); - while (remaining && !budget.compare_exchange_weak(remaining, remaining - 1)) {} - return remaining != 0; + std::lock_guard lock(requestsMutex); + requests.Clear(); } template T Read(const void* object, std::size_t offset) { @@ -52,273 +44,142 @@ template T Read(const void* object, std::size_t offset) std::memcpy(&value, static_cast(object) + offset, sizeof(value)); return value; } -struct Snapshot { - std::int64_t fleet{}, last{}, pending{}; - std::int64_t front{}, next{}; - int retries{-1}; - bool frontKnown{}, nextKnown{}; - float attempt{}; - int count{-1}, state{-1}; - bool engaging{}; - bool operator==(const Snapshot&) const = default; -}; -Snapshot Capture(Il2CppObject* queue, Il2CppObject* deployed = nullptr) +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) { - Snapshot s; - if (!queue) + QueueState s; + if (!queue || il2cpp_object_get_class(queue) != queueClass) return s; s.fleet = Read(queue, 0x30); - s.last = Read(queue, 0x18); - s.pending = Read(queue, 0x20); s.attempt = Read(queue, 0x14); s.engaging = Read(queue, 0x10); - if (deployed) - s.state = Read(deployed, 0x80); - // Inspect List's count only if the actual object exposes the expected field. - if (auto* list = Read(queue, 0x28)) { - auto* field = il2cpp_class_get_field_from_name(il2cpp_object_get_class(list), "_size"); - if (field && field->type && field->type->type == IL2CPP_TYPE_I4) - il2cpp_field_get_value(list, field, &s.count); - auto* itemsField = il2cpp_class_get_field_from_name(il2cpp_object_get_class(list), "_items"); - if (s.count > 0 && itemsField && itemsField->type && itemsField->type->type == IL2CPP_TYPE_SZARRAY) { - Il2CppArray* items{}; - il2cpp_field_get_value(list, itemsField, &items); - if (items && il2cpp_array_length(items) >= static_cast(s.count) - && il2cpp_class_get_element_class(il2cpp_object_get_class(reinterpret_cast(items))) - == actionClass) { - auto* array = reinterpret_cast(items); - auto* front = static_cast(array->vector[0]); - if (front && il2cpp_object_get_class(front) == actionClass) { - s.frontKnown = true; - s.front = Read(front, 0x10); - s.retries = Read(front, 0x18); - } - if (s.count > 1) { - auto* next = static_cast(array->vector[1]); - if (next && il2cpp_object_get_class(next) == actionClass) { - s.nextKnown = true; - s.next = Read(next, 0x10); - } - } - } - } - } - return s; -} -// Record the most recent native engagement attempt; weak handles prevent recycled object identities matching. -void Remember(Il2CppObject* queue, const Snapshot& s) -{ - std::lock_guard lock(requestsMutex); - auto* slot = &requests.front(); - for (auto& r : requests) { - if (r.fleet == s.fleet) { - slot = &r; - break; - } - if (r.started < slot->started) - slot = &r; - } - if (slot->queueHandle) - il2cpp_gchandle_free(slot->queueHandle); - *slot = {il2cpp_gchandle_new_weakref(queue, false), s.fleet, s.frontKnown ? s.front : 0, s.attempt, Clock::now()}; -} -bool ConsumeRequest(Il2CppObject* queue, const Snapshot& s, std::int64_t failedTarget) -{ - std::lock_guard lock(requestsMutex); - for (auto& r : requests) { - if (r.fleet != s.fleet) - continue; - const bool match = r.queueHandle && il2cpp_gchandle_get_target(r.queueHandle) == queue && r.target == failedTarget - && r.attempt == s.attempt && Clock::now() - r.started < std::chrono::seconds(30); - if (match) { - il2cpp_gchandle_free(r.queueHandle); - r = {}; - } - return match; - } - return false; -} -bool TargetAbsent(Il2CppObject* queue, std::int64_t target) -{ auto* list = Read(queue, 0x28); if (!list) - return false; + 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 false; - int count{}; + return s; Il2CppArray* items{}; - il2cpp_field_get_value(list, size, &count); + il2cpp_field_get_value(list, size, &s.count); il2cpp_field_get_value(list, storage, &items); - if (count <= 0 || count > 128 || !items || il2cpp_array_length(items) < static_cast(count) + 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 false; - auto* array = reinterpret_cast(items); - for (int i = 0; i < count; ++i) { + 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 || Read(action, 0x10) == target) - return false; + 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; } - return true; + s.valid = s.fleet != 0; + return s; } -Il2CppObject* FindQueue(Il2CppObject* manager, std::int64_t fleet) +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; - auto* values = reinterpret_cast(array); - for (unsigned i = 0; i < il2cpp_array_length(array); ++i) { - auto* queue = static_cast(values->vector[i]); + 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; } -// Preserve native cross-fleet cleanup: do not take the planner branch if any queue still holds this target. bool AbsentFromAllQueues(Il2CppObject* manager, std::int64_t target) { - 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) + auto* array = Queues(manager); + if (!array) return false; - auto* values = reinterpret_cast(array); - for (unsigned i = 0; i < il2cpp_array_length(array); ++i) { - auto* queue = static_cast(values->vector[i]); + for (unsigned i = 0; i < array->max_length; ++i) { + auto* queue = static_cast(array->vector[i]); if (!queue) continue; - if (il2cpp_object_get_class(queue) != queueClass) - return false; - const auto snapshot = Capture(queue); - if (snapshot.count < 0 || (snapshot.count > 0 && !TargetAbsent(queue, target))) + const auto s = Inspect(queue, target); + if (!s.valid || s.containsTarget) return false; } return true; } -void Log(const char* event, const Snapshot& s, int result = -1, std::int64_t sameSnapshotMs = -1, - std::int64_t target = 0) -{ - if (Reserve()) - spdlog::info( - "[QueueTrace] {} fleet={} count={} engaging={} last={} pending={} attempt={} state={} result={} " - "same_snapshot_observed_ms={} front_known={} front={} retries={} next_known={} next={} decision_target={}", - event, s.fleet, s.count, s.engaging, s.last, s.pending, s.attempt, s.state, result, sameSnapshotMs, - s.frontKnown, s.front, s.retries, s.nextKnown, s.next, target); -} -bool Changed(const Snapshot& s, std::int64_t& sameSnapshotMs) -{ - struct Record { - Snapshot snapshot; - Clock::time_point logged{}, firstObserved{}; - }; - static std::array records{}; - static std::mutex mutex; - std::lock_guard lock(mutex); - auto* record = &records.front(); - for (auto& entry : records) { - if (entry.snapshot.fleet == s.fleet) { - record = &entry; - break; - } - if (entry.logged < record->logged) - record = &entry; - } - const auto now = Clock::now(); - const bool same = record->logged != Clock::time_point{} && record->snapshot == s; - if (!same) - record->firstObserved = now; - // This is equality at native watchdog samples, not proof that nothing changed between them. - sameSnapshotMs = std::chrono::duration_cast(now - record->firstObserved).count(); - // Idle nonempty queues deserve closer observation even when IsEngaging blocks the native watchdog. - // Reuse its callbacks: no timer, polling loop, or changes to retry eligibility. - const auto interval = std::chrono::seconds(s.state == 0 && s.count != 0 ? 3 : 30); - if (same && now - record->logged < interval) - return false; - record->snapshot = s; - record->logged = now; - return true; -} -void Stall(auto original, Il2CppObject* manager, Il2CppObject* queue, Il2CppObject* player, Il2CppObject* deployed) -{ - try { - if (Active()) { - const auto snapshot = Capture(queue, deployed); - std::int64_t sameSnapshotMs{}; - if (Changed(snapshot, sameSnapshotMs)) - Log("watchdog-before", snapshot, -1, sameSnapshotMs); - } - } catch (...) { - } - original(manager, queue, player, deployed); -} +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) { - bool trace = false; - Snapshot before; - try { - trace = Active(); - if (trace || (recover && ready.load())) { - before = Capture(queue); - if (recover && ready.load()) - Remember(queue, before); + 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(); } - } catch (...) { - trace = false; + } else { + ClearRequests(); } const auto result = original(manager, player, queue); - // Retain primitives only across the native call; do not reread possibly-released objects. - try { - if (trace) - Log("engage-result", before, result); - } catch (...) { + // 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; } -// Capture only primitives before native code; a decision is not proof that removal occurred. bool Retry(auto original, Il2CppObject* manager, std::int64_t target, Il2CppObject* queue) { - bool trace = false; - Snapshot before; + const bool retry = original(manager, target, queue); + if (retry || !Enabled() || !currentCourse) + return retry; try { - trace = Active(); - if (trace || (recover && ready.load())) - before = Capture(queue); + 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 (...) { - trace = false; + return retry; } - const bool result = original(manager, target, queue); - try { - if (trace) - Log("retry-decision", before, result ? 1 : 0, -1, target); - } catch (...) { - } - // Course has already cleared IsEngaging. Only promote a non-retry after the exact outstanding - // target disappeared. Returning true uses Course's existing native planner/eligibility path. - try { - const auto* c = currentCourse; - if (!result && recover && ready.load() && c && c->failed && c->engagingAtEntry && c->queue == queue - && c->fleet == before.fleet && c->target == target && target != 0 && !before.engaging && before.frontKnown - && before.front != target && before.count > 0 && TargetAbsent(queue, target) - && AbsentFromAllQueues(manager, target) && ConsumeRequest(queue, before, target)) { - if (Active()) - Log("advance-removed-front", before, 1, -1, target); - return true; - } - } catch (...) { - } - return result; -} -void ProcessTarget(auto original, Il2CppObject* manager, std::int64_t target, bool canSelect) -{ - try { - if (Active() && Reserve()) - spdlog::info("[QueueTrace] process-target-before target={} can_select_new={}", target, canSelect); - } catch (...) { - } - original(manager, target, canSelect); } struct CourseResponse { std::int64_t fleet; @@ -330,23 +191,16 @@ static_assert(sizeof(CourseResponse) == 24 && offsetof(CourseResponse, target) = void Course(auto original, Il2CppObject* manager, CourseResponse args) { CourseContext context{args.fleet}; - try { - std::int64_t target{}; - if (args.target) { - const auto* name = il2cpp_class_get_name(il2cpp_object_get_class(args.target)); - if (name && std::strcmp(name, "Int64") == 0) - std::memcpy(&target, il2cpp_object_unbox(args.target), sizeof(target)); - } - context.target = target; - context.failed = !args.success && !args.recall; - if (recover && ready.load() && context.failed && target != 0) { - context.queue = FindQueue(manager, args.fleet); - context.engagingAtEntry = context.queue && Read(context.queue, 0x10); + 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 = {}; } - if (Active() && Reserve()) - spdlog::info("[QueueTrace] course-response fleet={} target={} success={} recall={}", args.fleet, target, - args.success, args.recall); - } catch (...) { } struct Scope { CourseContext* previous{currentCourse}; @@ -357,12 +211,16 @@ void Course(auto original, Il2CppObject* manager, CourseResponse args) } scope(&context); original(manager, args); } -bool Field(IL2CppClassHelper& cls, const char* name, std::ptrdiff_t offset) +void ClearAll(auto original, Il2CppObject* manager) { - if (!cls.isValidHelper()) - return false; - auto field = cls.GetField(name); - return field.isValidHelper() && field.offset() == offset; + // Native session end/invalidation/quit share this seam; release weak handles while IL2CPP is alive. + ClearRequests(); + original(manager); +} +bool Field(Il2CppClass* cls, const char* name, std::ptrdiff_t offset, Il2CppTypeEnum type) +{ + auto* field = cls ? il2cpp_class_get_field_from_name(cls, name) : nullptr; + return field && field->offset == offset && field->type && field->type->type == type; } bool Method(void* method, std::uintptr_t rva, unsigned extent, const std::array& bytes) { @@ -376,76 +234,63 @@ bool Method(void* method, std::uintptr_t rva, unsigned extent, const std::array< } } // namespace -void InstallActionQueueTrace() +void InstallActionQueueRecovery() { - char enabled[8]{}; - if (GetEnvironmentVariableA("STFC_QUEUE_TRACE", enabled, sizeof(enabled)) != 1 || enabled[0] != '1') + if (!Config::Get().faster_queue_recovery) return; - char recovery[8]{}; - recover = GetEnvironmentVariableA("STFC_QUEUE_RECOVER", recovery, sizeof(recovery)) == 1 && recovery[0] == '1'; - auto manager = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueManager"); - auto action = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "QueueableAction"); - actionClass = action.get_cls(); - auto queue = il2cpp_get_class_helper("Assembly-CSharp", "Prime.ActionQueue", "ActionQueueInstance"); - queueClass = queue.get_cls(); - auto deployed = - il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "FleetDeployedData"); - if (!manager.isValidHelper()) { - spdlog::warn("[QueueTrace] disabled: queue manager unavailable"); + 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; } - auto* stall = manager.GetMethodSpecial("HandleStall", [](int n, const Il2CppType**) { return n == 3; }); - auto* engage = + IL2CppClassHelper manager(cls); + auto* engage = manager.GetMethodSpecial("TryPlanPathAndEngageTarget", [](int n, const Il2CppType**) { return n == 2; }); - auto* retry = manager.GetMethodSpecial("ShouldRetryFailedSetCourse", [](int n, const Il2CppType** p) { + auto* retry = manager.GetMethodSpecial("ShouldRetryFailedSetCourse", [](int n, const Il2CppType** p) { return n == 2 && p && p[0] && !p[0]->byref && p[0]->type == IL2CPP_TYPE_I8; }); - auto* process = manager.GetMethodSpecial("ProcessQueue", [](int n, const Il2CppType** p) { - return n == 2 && p && p[0] && p[1] && !p[0]->byref && !p[1]->byref && p[0]->type == IL2CPP_TYPE_I8 - && p[1]->type == IL2CPP_TYPE_BOOLEAN; - }); - auto* courseInfo = manager.GetMethodInfoSpecial("OnSetCourseResponseEventHandler", [](int n, const Il2CppType** p) { + auto* clear = + manager.GetMethodSpecial("StopWatchdogAndClearAllQueues", [](int n, const Il2CppType**) { return n == 0; }); + auto* info = manager.GetMethodInfoSpecial("OnSetCourseResponseEventHandler", [](int n, const Il2CppType** p) { return n == 1 && p && p[0] && !p[0]->byref && p[0]->type == IL2CPP_TYPE_VALUETYPE; }); - auto* course = courseInfo ? reinterpret_cast(courseInfo->methodPointer) : nullptr; - auto* eventClass = courseInfo ? il2cpp_class_from_type(courseInfo->parameters[0]) : nullptr; - IL2CppClassHelper event(eventClass); - std::uint32_t alignment{}; - const bool valid = - Field(manager, "_battleQueue", 0x48) && Field(action, "k__BackingField", 0x10) - && Field(action, "SetCourseFailRetryCount", 0x18) + auto* course = info ? reinterpret_cast(info->methodPointer) : nullptr; + auto* event = info ? il2cpp_class_from_type(info->parameters[0]) : nullptr; + std::uint32_t alignment{}; + const bool valid = + event && il2cpp_class_value_size(event, &alignment) == sizeof(CourseResponse) + && Field(event, "k__BackingField", 0x10, IL2CPP_TYPE_I8) + && Field(event, "k__BackingField", 0x18, IL2CPP_TYPE_BOOLEAN) + && Field(event, "k__BackingField", 0x19, IL2CPP_TYPE_BOOLEAN) + && Field(event, "k__BackingField", 0x20, IL2CPP_TYPE_OBJECT) + && Field(cls, "_battleQueue", 0x48, IL2CPP_TYPE_SZARRAY) + && Field(queueClass, "IsEngaging", 0x10, IL2CPP_TYPE_BOOLEAN) + && Field(queueClass, "LastEngageAttemptTime", 0x14, IL2CPP_TYPE_R4) + && Field(queueClass, "k__BackingField", 0x30, IL2CPP_TYPE_I8) + && Field(queueClass, "_actionQueue", 0x28, IL2CPP_TYPE_GENERICINST) + && Field(actionClass, "k__BackingField", 0x10, IL2CPP_TYPE_I8) + && Method(engage, 0x1109f60, 2340, {0x4c, 0x89, 0x44, 0x24, 0x18, 0x48, 0x89, 0x54, 0x24, 0x10, 0x48, 0x89, + 0x4c, 0x24, 0x08, 0x53, 0x56, 0x57, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56}) && Method(retry, 0x110d280, 662, {0x48, 0x89, 0x5c, 0x24, 0x10, 0x48, 0x89, 0x6c, 0x24, 0x18, 0x57, 0x48, 0x83, 0xec, 0x20, 0x80, 0x3d, 0x7a, 0x04, 0xb1, 0x04, 0x00, 0x49, 0x8b}) - && Method(process, 0x110cb70, 367, {0x40, 0x53, 0x57, 0x41, 0x54, 0x41, 0x56, 0x41, 0x57, 0x48, 0x83, 0xec, - 0x30, 0x80, 0x3d, 0x89, 0x0b, 0xb1, 0x04, 0x00, 0x45, 0x0f, 0xb6, 0xf0}) - && eventClass && il2cpp_class_value_size(eventClass, &alignment) == sizeof(CourseResponse) - && Field(event, "k__BackingField", 0x10) && Field(event, "k__BackingField", 0x18) - && Field(event, "k__BackingField", 0x19) && Field(event, "k__BackingField", 0x20) && Method(course, 0x110d070, 514, {0x48, 0x89, 0x5c, 0x24, 0x18, 0x57, 0x48, 0x83, 0xec, 0x20, 0x80, 0x3d, 0x8e, 0x06, 0xb1, 0x04, 0x00, 0x48, 0x8b, 0xfa, 0x48, 0x8b, 0xd9, 0x75}) - && Field(queue, "IsEngaging", 0x10) && Field(queue, "LastEngageAttemptTime", 0x14) - && Field(queue, "LastEngagedTargetId", 0x18) && Field(queue, "PendingEngageTargetId", 0x20) - && Field(queue, "_actionQueue", 0x28) && Field(queue, "k__BackingField", 0x30) - && Field(deployed, "_stateContainer", 0x80) - && Method(stall, 0x110df70, 341, {0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, - 0x74, 0x24, 0x18, 0x57, 0x48, 0x83, 0xec, 0x40, 0x0f, 0x29, 0x74, 0x24}) - && Method(engage, 0x1109f60, 2340, {0x4c, 0x89, 0x44, 0x24, 0x18, 0x48, 0x89, 0x54, 0x24, 0x10, 0x48, 0x89, - 0x4c, 0x24, 0x08, 0x53, 0x56, 0x57, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56}); + && Method(clear, 0x110bcc0, 493, {0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x70, 0x48, 0x8b, + 0xd9, 0x80, 0x3d, 0x33, 0x1a, 0xb1, 0x04, 0x00, 0x75, 0x5c, 0x48, 0x8d}); if (!valid) { - spdlog::warn("[QueueTrace] disabled: native build/layout does not match validated client261"); + spdlog::warn("[FasterQueueRecovery] unavailable: requires verified Windows x64 client 261 layout"); return; } - const bool first = SPUD_STATIC_DETOUR(stall, Stall) != nullptr; - const bool second = SPUD_STATIC_DETOUR(engage, Engage) != nullptr; - const bool third = SPUD_STATIC_DETOUR(course, Course) != nullptr; - const bool fourth = SPUD_STATIC_DETOUR(retry, Retry) != nullptr; - const bool fifth = SPUD_STATIC_DETOUR(process, ProcessTarget) != nullptr; - deadline = Clock::now() + std::chrono::minutes(30); - ready.store(first && second && third && fourth && fifth); - spdlog::info("[QueueTrace] ready={} recovery={} budget=1024 window=30min; engage result 0=success 1=skip " - "2=stop; retry result 0=no 1=yes", - ready.load(), recover); + 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 InstallActionQueueTrace() {} +void InstallActionQueueRecovery() {} #endif diff --git a/mods/src/patches/parts/action_queue.h b/mods/src/patches/parts/action_queue.h new file mode 100644 index 000000000..3e0f1e06e --- /dev/null +++ b/mods/src/patches/parts/action_queue.h @@ -0,0 +1,104 @@ +#pragma once + +#include +#include +#include + +namespace action_queue +{ +using Clock = std::chrono::steady_clock; + +struct QueueState { + bool valid{}; + std::int64_t fleet{}, front{}; + float attempt{}; + int count{}; + bool engaging{}, containsTarget{}; +}; + +// The pending-target field describes the last successful response, not necessarily the outstanding request. +inline bool CanAdvance(const QueueState& state, std::int64_t failedFleet, std::int64_t failedTarget, + bool outstandingAtEntry, bool sameQueue, bool absentFromAllQueues) +{ + return state.valid && sameQueue && outstandingAtEntry && failedTarget != 0 && state.fleet == failedFleet + && state.count > 0 && state.front != failedTarget && !state.engaging && !state.containsTarget + && absentFromAllQueues; +} + +// Handles provides weak identity without keeping native queues alive. Calls are serialized by the adapter. +template class Requests +{ +public: + using Object = typename Handles::Object; + using Handle = typename Handles::Handle; + + std::uint64_t Remember(Object queue, const QueueState& state, Clock::time_point now) + { + auto* slot = &records_.front(); + for (auto& record : records_) { + if (record.handle && (now - record.started >= lifetime || !Handles::Get(record.handle))) + Release(record); + if (record.fleet == state.fleet) { + slot = &record; + break; + } + if (record.started < slot->started) + slot = &record; + } + Release(*slot); + const auto serial = ++serial_; + if (state.valid && state.count > 0 && state.front != 0) + *slot = {Handles::New(queue), state.fleet, state.front, state.attempt, now, serial}; + return serial; + } + + // A native attempt that did not dispatch must not authorize a later failure response. + void Cancel(std::uint64_t serial) + { + for (auto& record : records_) + if (record.serial == serial) + Release(record); + } + + bool Consume(Object queue, const QueueState& state, std::int64_t target, Clock::time_point now) + { + for (auto& record : records_) { + if (record.fleet != state.fleet) + continue; + if (!record.handle || now - record.started >= lifetime || !Handles::Get(record.handle)) { + Release(record); + return false; + } + if (Handles::Get(record.handle) != queue || record.target != target || record.attempt != state.attempt) + return false; + Release(record); + return true; + } + return false; + } + + void Clear() + { + for (auto& record : records_) + Release(record); + } + +private: + struct Record { + Handle handle{}; + std::int64_t fleet{}, target{}; + float attempt{}; + Clock::time_point started{}; + std::uint64_t serial{}; + }; + static void Release(Record& record) + { + if (record.handle) + Handles::Free(record.handle); + record = {}; + } + static constexpr auto lifetime = std::chrono::seconds(30); + std::array records_{}; + std::uint64_t serial_{}; +}; +} // namespace action_queue diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index 698af802d..c88d392ca 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -48,7 +48,7 @@ void InstallDoubleClickAssignShipHooks(); void InstallInstantWarpConfirmationHooks(); void InstallForbiddenTechConfirmationHooks(); void InstallAudioEventHooks(); -void InstallActionQueueTrace(); +void InstallActionQueueRecovery(); __int64 il2cpp_init_hook(auto original, const char* domain_name) { @@ -172,7 +172,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) } } - InstallActionQueueTrace(); + InstallActionQueueRecovery(); spdlog::info(""); diff --git a/tests/action_queue.cc b/tests/action_queue.cc new file mode 100644 index 000000000..39028fb27 --- /dev/null +++ b/tests/action_queue.cc @@ -0,0 +1,120 @@ +#include "patches/parts/action_queue.h" +#include +#include +#include + +namespace +{ +struct Handles { + using Object = int; + using Handle = unsigned; + static inline unsigned serial{}; + static inline std::map live; + static Handle New(Object object) + { + live[++serial] = object; + return serial; + } + static Object Get(Handle handle) + { + auto it = live.find(handle); + return it == live.end() ? 0 : it->second; + } + static void Free(Handle handle) + { assert(live.erase(handle) == 1); } + static void Collect(Object object) + { + for (auto& [_, value] : live) + if (value == object) + value = 0; + } +}; +using Requests = action_queue::Requests; +using action_queue::CanAdvance; +using action_queue::QueueState; +using namespace std::chrono_literals; +const auto now = action_queue::Clock::time_point{} + 100s; +QueueState dispatch{true, 10, 100, 2.5f, 2, false, false}; +QueueState remaining{true, 10, 200, 2.5f, 1, false, false}; +} // namespace +int main() +{ + Requests requests; + // Removed target: exactly one immediate handoff. Pending-success state is intentionally not an input. + requests.Remember(1, dispatch, now); + assert(CanAdvance(remaining, 10, 100, true, true, true)); + assert(requests.Consume(1, remaining, 100, now + 1s)); + assert(!requests.Consume(1, remaining, 100, now + 2s)); + assert(Handles::live.empty()); + + // Native retry remains responsible while the target is still queued, even after reordering. + auto reordered = remaining; + reordered.containsTarget = true; + assert(!CanAdvance(reordered, 10, 100, true, true, true)); + assert(!CanAdvance(dispatch, 10, 100, true, true, true)); + // Cross-fleet target cleanup must retain native false-branch behavior without consuming the request. + requests.Remember(1, dispatch, now); + assert(!CanAdvance(remaining, 10, 100, true, true, false)); + assert(requests.Consume(1, remaining, 100, now + 1s)); + // Invalid identity, missing/empty queue, ongoing engagement and absent outstanding request fail closed. + assert(!CanAdvance(remaining, 11, 100, true, true, true)); + assert(!CanAdvance(remaining, 10, 100, false, true, true)); + assert(!CanAdvance(remaining, 10, 100, true, false, true)); + assert(!CanAdvance(remaining, 10, 0, true, true, true)); + auto invalid = remaining; + invalid.valid = false; + assert(!CanAdvance(invalid, 10, 100, true, true, true)); + auto empty = remaining; + empty.count = 0; + assert(!CanAdvance(empty, 10, 100, true, true, true)); + auto active = remaining; + active.engaging = true; + assert(!CanAdvance(active, 10, 100, true, true, true)); + + // Latest target/attempt supersedes old failures; pointer reuse after collection cannot resurrect identity. + requests.Remember(1, dispatch, now); + assert(!requests.Consume(2, remaining, 100, now)); + auto newer = dispatch; + newer.front = 300; + newer.attempt = 3; + requests.Remember(1, newer, now + 1s); + assert(!requests.Consume(1, remaining, 100, now + 2s)); + requests.Remember(1, dispatch, now); + auto differentAttempt = remaining; + differentAttempt.attempt = 9; + assert(!requests.Consume(1, differentAttempt, 100, now)); + Handles::Collect(1); + assert(!requests.Consume(1, remaining, 100, now)); + assert(Handles::live.empty()); + + // Canceling an older reentrant call cannot cancel the newer dispatch. + auto old = requests.Remember(1, dispatch, now); + requests.Remember(1, dispatch, now + 1s); + requests.Cancel(old); + assert(requests.Consume(1, remaining, 100, now + 2s)); + auto failed = requests.Remember(1, dispatch, now); + requests.Cancel(failed); + assert(!requests.Consume(1, remaining, 100, now)); + + requests.Remember(1, dispatch, now); + assert(!requests.Consume(1, remaining, 100, now + 30s)); + assert(Handles::live.empty()); + // Bounded capacity, independent fleets and session cleanup release all handles. + for (int i = 1; i <= 9; ++i) { + auto s = dispatch; + s.fleet = i; + requests.Remember(i, s, now + std::chrono::seconds(i)); + assert(Handles::live.size() <= 8); + } + auto first = remaining; + first.fleet = 1; + assert(!requests.Consume(1, first, 100, now + 10s)); + auto last = remaining; + last.fleet = 9; + assert(requests.Consume(9, last, 100, now + 10s)); + requests.Clear(); + requests.Clear(); + assert(Handles::live.empty()); + assert(!requests.Consume(2, remaining, 100, now)); + std::cout << "Faster Queue Recovery policy and request-lifecycle tests passed\n"; +} diff --git a/tests/run-action-queue.ps1 b/tests/run-action-queue.ps1 new file mode 100644 index 000000000..5b80eb822 --- /dev/null +++ b/tests/run-action-queue.ps1 @@ -0,0 +1,12 @@ +# Standalone policy tests: no game, IL2CPP, or mod DLL required. +[CmdletBinding()] +param() +$ErrorActionPreference = 'Stop' +Push-Location (Split-Path -Parent $PSScriptRoot) +try { + New-Item -ItemType Directory -Path build -Force | Out-Null + & clang++ -std=c++23 -Imods/src tests/action_queue.cc -o build/action_queue_tests.exe + if ($LASTEXITCODE -ne 0) { throw 'Action queue test compilation failed.' } + & ./build/action_queue_tests.exe + if ($LASTEXITCODE -ne 0) { throw 'Action queue regression failed.' } +} finally { Pop-Location } From 600eaab14213a135ede8c289e069db2e2b25f6ea Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Wed, 16 Sep 2026 01:11:26 -0500 Subject: [PATCH 10/11] Mark former queue completion repair as fixed by Scopely --- docs/FASTER_QUEUE_RECOVERY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/FASTER_QUEUE_RECOVERY.md b/docs/FASTER_QUEUE_RECOVERY.md index cb1470485..f1493dba8 100644 --- a/docs/FASTER_QUEUE_RECOVERY.md +++ b/docs/FASTER_QUEUE_RECOVERY.md @@ -39,7 +39,7 @@ One detour owns each method. Installation checks metadata, exact RVA, PE unwind 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. -Do not install the older fork queue repair alongside this implementation without consolidating the shared course-response detour. +**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 From 164f3c6dd6f89a5015ab20bc0d3a121916d900ba Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Wed, 16 Sep 2026 01:42:45 -0500 Subject: [PATCH 11/11] Keep queue policy fixture local without a dedicated CI job --- .github/workflows/ci.yaml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b2b3427ff..c4db85207 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,15 +31,6 @@ env: XMAKE_ACTION_BUILD_CACHE: "true" jobs: - action-queue-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - name: Test queue recovery policy and request lifecycle - run: | - clang++ -std=c++23 -Imods/src tests/action_queue.cc -o /tmp/action-queue-tests - /tmp/action-queue-tests - build-win: runs-on: windows-2025-vs2026 outputs: