diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index aae461323..c09dbffde 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,8 +20,6 @@ env: jobs: build-win: runs-on: windows-2025-vs2026 - outputs: - run_attempt: ${{ steps.build_identity.outputs.run_attempt }} steps: - uses: actions/checkout@v7 - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 @@ -39,10 +37,8 @@ jobs: run: | if ($env:STFC_WINDOWS_TOOLCHAIN_URL) { zig build fetch-toolchains } - name: Build and test - id: build_identity shell: pwsh run: | - "run_attempt=${{ github.run_attempt }}" >> $env:GITHUB_OUTPUT zig build install test-toolchains test-codegen test-deps test-il2cpp test-mod test-sync test-spud ` -Dtarget=x86_64-windows-msvc -Doptimize=ReleaseFast -Dtest-optimize=Debug ` "-Dstfc_source_state_id=git:${{ github.sha }}" ` @@ -51,7 +47,9 @@ jobs: -Dstfc_build_channel=ci if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Verify proxy binary contract - run: python scripts/verify-windows-binary.py + run: >- + python scripts/verify-windows-binary.py --identity + "stfc-identity-v1;distribution=netniv.stfc-community-mod;source=git:${{ github.sha }};base=${{ github.sha }};build=github:${{ github.run_id }}:${{ github.run_attempt }}:windows;mode=release;channel=ci" - uses: actions/upload-artifact@v7.0.1 with: name: stfc-community-mod @@ -154,36 +152,6 @@ jobs: name: stfc-community-mod path: .artifacts/windows - - name: Verify DLL provenance - shell: pwsh - env: - STFC_SOURCE_COMMIT: ${{ github.sha }} - STFC_RUN_ID: ${{ github.run_id }} - STFC_BUILD_RUN_ATTEMPT: ${{ needs.build-win.outputs.run_attempt }} - run: | - $dll = (Resolve-Path -LiteralPath '.artifacts/windows/version.dll').Path - $commit = $env:STFC_SOURCE_COMMIT - $runId = $env:STFC_RUN_ID - $buildAttempt = $env:STFC_BUILD_RUN_ATTEMPT - - if ($commit -notmatch '^[0-9a-f]{40}$') { - throw "Unable to resolve the workflow commit for DLL provenance." - } - if ($runId -notmatch '^[1-9][0-9]*$') { - throw "Unable to resolve the workflow run ID for DLL provenance." - } - if ($buildAttempt -notmatch '^[1-9][0-9]*$') { - throw "The producing build did not provide a valid run attempt." - } - - $expected = "stfc-identity-v1;distribution=netniv.stfc-community-mod;source=git:$commit;" + - "base=$commit;build=github:${runId}:${buildAttempt}:windows;mode=release;channel=ci" - $comments = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dll).Comments - if (-not [string]::Equals($comments, $expected, [System.StringComparison]::Ordinal)) { - throw "DLL provenance mismatch. Expected '$expected'; found '$comments'." - } - Write-Host "Verified DLL provenance: $comments" - - name: Attest Windows DLL uses: actions/attest@v4.2.1 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d63523b58..435811c5c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -220,7 +220,7 @@ It uses text matching. Runtime type checks and in-game validation remain necessa | `sync/state.cc` | Comparisons, removals, and per-target acknowledgements | | `sync/upload.cc` | Sessions, the record store, target workers, and HTTP delivery | | `sync/battlelogs.cc` | Battle-journal fetching | -| `sync/fleets.cc` | Client fleet records: slot polling, baselines, state changes, snapshots, and late re-reads, on the game thread | +| `sync/fleets.cc` | Client fleet records: slot polling, state changes, snapshots, and late re-reads, on the game thread | Payload construction and HTTP run outside the store lock. Session generations prevent old responses and acknowledgements from changing a new login. @@ -262,18 +262,9 @@ Review source and configuration changes, then build supported targets. macOS uses Apple's system libcurl; Windows builds pinned curl with Schannel. Neither builds OpenSSL. Librsync uses checked-in configuration files. Normal builds need no Perl or CMake. -Windows build-identity options have matching environment fallbacks: - -| Zig option | Environment variable | -| --- | --- | -| `stfc_source_state_id` | `STFC_SOURCE_STATE_ID` | -| `stfc_base_commit` | `STFC_BASE_COMMIT` | -| `stfc_build_invocation_id` | `STFC_BUILD_INVOCATION_ID` | -| `stfc_build_channel` | `STFC_BUILD_CHANNEL` | - -Prefix options with `-D`, as in `-Dstfc_build_channel=local`. -Zig 0.17 cannot track environment variables in its configure cache, so reading a fallback configures the build again on every run. +Windows builds take their identity from `-Dstfc_source_state_id`, `-Dstfc_base_commit`, `-Dstfc_build_invocation_id` +and `-Dstfc_build_channel`, as in `-Dstfc_build_channel=local`. Values accept 1 to 160 ASCII letters, digits, or `._:+/-`. -The DLL version resource contains the same values. +They form the `stfc-identity-v1` marker in the DLL version resource, which CI checks with `scripts/verify-windows-binary.py --identity`. Keep CI artifact names compatible with the upstream Windows attestation and macOS release jobs. See [Windows DLL verification](docs/WINDOWS_DLL_PROVENANCE.md) and [launcher version generation](docs/VERSION_SUBSTITUTION.md). diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 328418bcb..3898eaa66 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -55,7 +55,7 @@ The [shortcut reference](KEYMAPPING.md) lists the defaults. ## Rewards and interface - Skip chest reveals, open bulk gift claims, and leave the section after the supported gift-collection flow. -- Extend Alliance donations and, on Windows, supported tagged chest-purchase sliders. +- Extend Alliance donations and supported tagged chest-purchase sliders. - Limit Daily Goals **Claim All** to selected Federation, Klingon, or Romulan goals. Its toggle can start enabled each session. `ui.daily_bulk_claim_factions` lists the factions; blank means all three, and `none` by itself excludes all of them. Other dailies stay eligible. - Hide Galaxy or Veil Chat, and player titles under chat names. Set supported HUD buttons to automatic, always shown, or always hidden. diff --git a/docs/IL2CPP.md b/docs/IL2CPP.md index 318ac174a..626a03e9e 100644 --- a/docs/IL2CPP.md +++ b/docs/IL2CPP.md @@ -89,8 +89,6 @@ A missing or drifted class or member is reported once. Calls then do nothing and return a value-initialized result. A managed exception is contained at the binding call, reported once per method, and produces the same default result. It cannot unwind through the hook and skip the game's code. -`TryCall` is the same call for code that undoes its own changes when the game's code fails. -It returns an empty `std::optional`, or `false` for a void method, when the method is missing or threw. Receivers must be non-null. Check pointers returned by the game before calling methods through them. @@ -180,17 +178,14 @@ Change hidden `MethodInfo*` handling only when runtime evidence points to it. | `il2cpp::Allocate()` | Allocate without a constructor, for classes whose parameterless constructor has no generated code | | `NewString`, `ToUtf8`, `Equals` | Create, convert, or compare managed strings | | `ToString` | A managed string for any object | -| `Box`, `Unbox` | Box a primitive, or read one of exactly that type | +| `Box` | Box a primitive | | `Nullable` | `System.Nullable` by value | | `Is`, `As` | Test or cast a receiver against a bound class | | `InvokeBound` | Run a single-cast delegate's method directly; managed exceptions propagate so the caller can fall back | | `NewDelegate` | Bind a delegate to a static method, with an optional native redirect | -| `Guarded` | Contain Windows access violations when reading layouts that may have drifted | The GC scans native stacks, so managed pointers in local variables remain visible to it. Pointers stored in C++ statics or heap objects need `GcHandle::Strong` roots. -`GcRoots` holds strong roots for the objects one operation touches and releases them together. -Its `healthy()` turns false once a root could not be taken. Release roots before runtime shutdown. Roots do not destroy Unity native resources. `unity::Track()` tracks a class and its subclasses from hook installation onward. diff --git a/src/libs/il2cpp/include/il2cpp/il2cpp.h b/src/libs/il2cpp/include/il2cpp/il2cpp.h index c4bab95f8..6f51775f1 100644 --- a/src/libs/il2cpp/include/il2cpp/il2cpp.h +++ b/src/libs/il2cpp/include/il2cpp/il2cpp.h @@ -15,7 +15,6 @@ #include #include #include -#include #include #include #include @@ -23,7 +22,6 @@ #include #include #include -#include namespace il2cpp { @@ -93,7 +91,6 @@ namespace detail Il2CppClass* TypeClass(); // System.Int32 and friends from corlib, by element type; null for anything else. Il2CppClass* PrimitiveClass(Il2CppTypeEnum type); - void ReportFault(const char* what); void ReportMissingICall(const char* name); template @@ -287,34 +284,6 @@ namespace detail return R(); } - // Invoke for TryCall: empty (false for void) when the method is missing, the receiver is null, or a managed - // exception stopped the call. Exceptions are reported as Invoke reports them. - template using Tried = std::conditional_t, bool, std::optional>; - template Tried TryInvoke(const MethodInfo* method, Il2CppObject* target, A... args) - { - const bool is_static = method && (method->flags & METHOD_ATTRIBUTE_STATIC); - if (!method || (!is_static && !target)) - return {}; - if (!is_static && (method->flags & METHOD_ATTRIBUTE_VIRTUAL)) { - method = il2cpp_object_get_virtual_method(target, method); - if (!method) - return {}; - } - try { - if constexpr (std::is_void_v) { - InvokeNative(method, target, args...); - return true; - } else { - return InvokeNative(method, target, args...); - } - } catch (const Il2CppExceptionWrapper& error) { - ReportException(method, error.ex); - } catch (...) { - ReportException(method, nullptr); - } - return {}; - } - // Call<"Name", F>: F is either the result type, with parameters taken from the argument types (CallOf), // or a full signature R(P...). Param converts each argument to P with brace initialization, which rejects // narrowing, and turns a T& parameter into the std::reference_wrapper the deduced form uses for ref/out. @@ -338,11 +307,6 @@ namespace detail static_assert(sizeof...(A) == sizeof...(P), "argument count does not match the signature"); return Invoke::Type...>(method, target, Param

::From(std::forward(args))...); } - template static Tried TryRun(const MethodInfo* method, Il2CppObject* target, A&&... args) - { - static_assert(sizeof...(A) == sizeof...(P), "argument count does not match the signature"); - return TryInvoke::Type...>(method, target, Param

::From(std::forward(args))...); - } }; template struct CallOf : MethodCall { }; @@ -515,15 +479,6 @@ struct Object : Il2CppObject { auto* method = detail::Lookup(self, [](Il2CppClass* klass) { return Sig::Resolve(klass, N.View(), false); }); return Sig::Run(method, &self, std::forward(args)...); } - // Call, except that a failed call is told apart from a result that reads as zero: empty, or false for a void - // method, when the method is missing or a managed exception (still reported) stopped it. For callers that undo - // their own changes when the game's code fails. - template auto TryCall(this Self& self, A&&... args) - { - using Sig = detail::CallOf...>; - auto* method = detail::Lookup(self, [](Il2CppClass* klass) { return Sig::Resolve(klass, N.View(), false); }); - return Sig::TryRun(method, &self, std::forward(args)...); - } template bool Is() const { @@ -573,15 +528,6 @@ template Il2CppObject* Box(T value) return klass ? il2cpp_value_box(klass, &value) : nullptr; } -// Reads a boxed primitive of exactly type T. False, leaving `value` alone, for null or any other type. -template bool Unbox(const Il2CppObject* object, T& value) -{ - if (!object || object->klass != PrimitiveClass()) - return false; - std::memcpy(&value, il2cpp_object_unbox(const_cast(object)), sizeof(T)); - return true; -} - // System.Nullable as the runtime lays it out, for members that take or return one: the flag, then the value. template struct Nullable { bool has_value; @@ -728,26 +674,6 @@ Il2CppObject* NewDelegate(Il2CppClass* delegate_class, Method target, Il2CppMeth template R InvokeBound(Il2CppDelegate* delegate, A... args) { return detail::InvokeNative(delegate->method, delegate->target, args...); } -// --- Faults --- - -// Runs `read` and, on Windows, survives an access violation inside it: reading game objects whose layout drifted -// between client builds should cost a log line, not the game. False, after reporting `what`, when one was caught. -template bool Guarded(const char* what, Fn&& read) -{ -#if _WIN32 - __try { - read(); - } __except (1 /* EXCEPTION_EXECUTE_HANDLER */) { - detail::ReportFault(what); - return false; - } -#else - (void)what; - read(); -#endif - return true; -} - // --- GC roots --- // Move-only managed root. Weak handles do not retain the managed object. @@ -796,34 +722,6 @@ class GcHandle Il2CppGCHandle handle_ = nullptr; }; -// Strong roots for the objects one operation touches, released together when it ends. Keep returns its argument, -// or null after marking the set unhealthy when the runtime could not root it. Null needs no root. -class GcRoots -{ -public: - Il2CppObject* Keep(Il2CppObject* object) - { - if (!object) - return nullptr; - auto root = GcHandle::Strong(object); - if (root.Get() != object) { - healthy_ = false; - return nullptr; - } - roots_.push_back(std::move(root)); - return object; - } - template T* Keep(T* object) - { return reinterpret_cast(Keep(reinterpret_cast(object))); } - // False once any Keep failed. - bool healthy() const - { return healthy_; } - -private: - std::vector roots_; - bool healthy_ = true; -}; - // --- Internal calls --- // A Unity engine internal call. One that a Unity upgrade renamed is reported once, when resolved, and then returns diff --git a/src/libs/il2cpp/src/il2cpp.cc b/src/libs/il2cpp/src/il2cpp.cc index fe95c49c4..d09ec2d44 100644 --- a/src/libs/il2cpp/src/il2cpp.cc +++ b/src/libs/il2cpp/src/il2cpp.cc @@ -269,9 +269,6 @@ namespace detail return klass; } - void ReportFault(const char* what) - { Report(std::format("[IL2CPP] access violation while reading {}", what)); } - Il2CppClass* FindClass(std::string_view assembly, std::string_view namespaze, std::string_view name) { const std::string namespace_name(namespaze); diff --git a/src/libs/unity/include/unity/core.h b/src/libs/unity/include/unity/core.h index 124b3cabc..5412ab656 100644 --- a/src/libs/unity/include/unity/core.h +++ b/src/libs/unity/include/unity/core.h @@ -70,9 +70,6 @@ struct UnityObject { inline bool Alive(Il2CppObject* object) { return object && UnityObject::Class::Call<"op_Implicit", bool>(object); } -inline void Destroy(Il2CppObject* object) -{ UnityObject::Class::Call<"Destroy">(object); } - // The first loaded object of T. Scans every loaded object; keep it off per-frame paths. template T* FindObject() { @@ -121,9 +118,6 @@ struct GameObject : il2cpp::Object { { return Get<"transform", Transform*>(); } void SetActive(bool active) { Call<"SetActive">(active); } - // Whether it and every parent are active. - bool activeInHierarchy() - { return Get<"activeInHierarchy", bool>(); } // Passing System.Type selects the (Type) overloads over the string and generic ones. A null Type would throw in // managed code, so a missing class yields null instead. @@ -325,8 +319,6 @@ struct Material : il2cpp::Object { { return il2cpp::ToUtf8(Get<"name", Il2CppString*>()); } Shader* shader() { return Get<"shader", Shader*>(); } - bool HasProperty(Il2CppString* name) - { return Call<"HasProperty", bool>(name); } float GetFloat(Il2CppString* name) { return Call<"GetFloat", float>(name); } void SetFloat(Il2CppString* name, float value) diff --git a/src/mod/features/action_queue.cc b/src/mod/features/action_queue.cc index 4f74bc813..9d2589fd3 100644 --- a/src/mod/features/action_queue.cc +++ b/src/mod/features/action_queue.cc @@ -18,15 +18,11 @@ #include #include -#include -#include #include #include namespace { -using Clock = action_queue::Clock; - // The only check a hook needs: each fix installs whole or not at all, so a hook that runs has its partners. bool QueueFixesEnabled() { @@ -61,7 +57,7 @@ ActionQueueInstance* FindQueue(ActionQueueManager* manager, int64_t fleet_id) return nullptr; } -// Empty, which reads as no queue, when the queue or any captured action cannot be read. +// Empty, which reads as no queue, when the queue or any action cannot be read. The game bounds the queue's length. action_queue::QueueSnapshot SnapshotOf(ActionQueueInstance* queue) { auto* actions = queue ? queue->actionQueue() : nullptr; @@ -69,43 +65,27 @@ action_queue::QueueSnapshot SnapshotOf(ActionQueueInstance* queue) return {}; action_queue::QueueSnapshot snapshot{ .player_fleet_id = queue->PlayerFleetId(), - .count = actions->Count(), .is_engaging = queue->IsEngaging(), .last_engaged_target_id = queue->LastEngagedTargetId(), .pending_target_id = queue->PendingEngageTargetId(), }; - for (auto* action : *actions | std::views::take(snapshot.target_ids.size())) { + for (auto* action : *actions) { const auto target = action ? action->FleetId() : 0; if (!target) return {}; - snapshot.target_ids[snapshot.captured_target_count++] = target; + snapshot.targets.push_back(target); } - snapshot.targets_truncated = snapshot.count > snapshot.captured_target_count; - snapshot.head_target_id = snapshot.count ? snapshot.target_ids[0] : 0; - snapshot.present = snapshot.player_fleet_id != 0; + snapshot.present = snapshot.player_fleet_id != 0; return snapshot; } // --- Faster queue recovery --- -// Shared by four hooks; locked rather than assuming they all run on the game thread. -std::mutex recovery_mutex; -// Never destroyed: freeing a GC handle in a static destructor can run after the runtime has shut down. -auto& recovery_requests = *new action_queue::Requests; - -// What the course response handler knew on entry, for the retry decision it makes inside. -struct CourseContext { - int64_t fleet_id{}, target_id{}; - ActionQueueInstance* queue{}; - bool engaging_at_entry{}; -}; -thread_local CourseContext* current_course = nullptr; +action_queue::Attempts recovery_attempts; -void ClearRequests() -{ - std::lock_guard lock(recovery_mutex); - recovery_requests.Clear(); -} +// Set while the course response handler runs for a failed, non-recall response whose queue was engaging on entry. +// Only then may the retry decision the handler makes inside hand over to the planner. +thread_local bool failed_while_engaging = false; // `target`, when given, is checked against every queued action; otherwise only the head is read. action_queue::RetryState RetryStateOf(ActionQueueInstance* queue, int64_t target = 0) @@ -153,46 +133,30 @@ bool AbsentFromAllQueues(ActionQueueManager* manager, int64_t target) EngageResult ActionQueueManager_TryPlanPathAndEngageTarget_Hook(auto original, ActionQueueManager* _this, FleetPlayerData* fleet, ActionQueueInstance* queue) { - uint64_t serial = 0; - if (QueueFixesEnabled()) { - const auto state = RetryStateOf(queue); - std::lock_guard lock(recovery_mutex); - serial = recovery_requests.Remember(queue, state, Clock::now()); - } else { - ClearRequests(); - } + if (!QueueFixesEnabled() || !fleet) + return original(_this, fleet, queue); + const auto slot = fleet->Index(); + recovery_attempts.Remember(slot, RetryStateOf(queue)); const auto result = original(_this, fleet, queue); // Only a dispatched attempt can be answered by a course response. - if (serial && result != EngageResult::Success) { - std::lock_guard lock(recovery_mutex); - recovery_requests.Cancel(serial); - } + if (result != EngageResult::Success) + recovery_attempts.Forget(slot); return result; } -// Called by the game from inside OnSetCourseResponseEventHandler. +// Called by the game from inside OnSetCourseResponseEventHandler, for the failed target and the response fleet's +// queue. bool ActionQueueManager_ShouldRetryFailedSetCourse_Hook(auto original, ActionQueueManager* _this, int64_t target, ActionQueueInstance* queue) { - const bool enabled = QueueFixesEnabled(); - if (!enabled) - ClearRequests(); const bool retry = original(_this, target, queue); - if (retry || !enabled || !current_course) - return retry; - const auto& course = *current_course; - const auto state = RetryStateOf(queue, target); - // Absence from every queue is checked below, last, because it reads all of them. - if (course.target_id != target - || !action_queue::CanAdvance(state, course.fleet_id, target, course.engaging_at_entry, course.queue == queue, - true)) + if (retry || !failed_while_engaging) return retry; + const auto state = RetryStateOf(queue, target); // The native false branch removes the target from every fleet's queue. Leave that to it while any queue might - // still hold the target. - if (!AbsentFromAllQueues(_this, target)) - return retry; - std::lock_guard lock(recovery_mutex); - if (!recovery_requests.Consume(queue, state, target, Clock::now())) + // still hold the target. Checked after the cheap test because it reads every queue. + if (!action_queue::CanAdvance(state, target) || !AbsentFromAllQueues(_this, target) + || !recovery_attempts.Consume(state, target)) return retry; // True takes the handler's retry branch, which runs the native planner for this fleet with all of its checks. return true; @@ -201,31 +165,20 @@ bool ActionQueueManager_ShouldRetryFailedSetCourse_Hook(auto original, ActionQue void ActionQueueManager_OnSetCourseResponseEventHandler_Hook(auto original, ActionQueueManager* _this, SetCourseResponse response) { - const bool enabled = QueueFixesEnabled(); - if (!enabled) - ClearRequests(); - CourseContext course{response.fleet_id}; - if (enabled && !response.success && !response.is_recall && il2cpp::Unbox(response.target_id, course.target_id)) { - course.queue = FindQueue(_this, response.fleet_id); - course.engaging_at_entry = course.queue && course.queue->IsEngaging(); - } - QueueCallScope scope(current_course, &course); + bool engaging = false; + if (!response.success && !response.is_recall && QueueFixesEnabled()) + if (auto* queue = FindQueue(_this, response.fleet_id)) + engaging = queue->IsEngaging(); + QueueCallScope scope(failed_while_engaging, engaging); original(_this, response); } -// Session end, invalidation and quit all pass through here, while the runtime can still free the weak handles. -void ActionQueueManager_StopWatchdogAndClearAllQueues_Hook(auto original, ActionQueueManager* _this) -{ - ClearRequests(); - original(_this); -} - // --- Thin queue protection --- ActionQueueInstance* FindQueueWithHead(ActionQueueManager* manager, int64_t target) { for (auto* queue : il2cpp::Items(manager->battleQueue())) - if (const auto snapshot = SnapshotOf(queue); snapshot.present && snapshot.head_target_id == target) + if (const auto snapshot = SnapshotOf(queue); snapshot.present && snapshot.Head() == target) return queue; return nullptr; } @@ -235,18 +188,15 @@ bool ResumeAfterPrune(ActionQueueManager* manager, ActionQueueInstance* queue, F const action_queue::QueueSnapshot& before, const action_queue::QueueSnapshot& after, const char* source) { - if (!action_queue::IsNativePruneResumeCandidate(QueueFixesEnabled(), idle, before, after)) - return false; - const auto confirmed = SnapshotOf(queue); - if (!action_queue::IsStableResumePostcondition(after, confirmed)) + if (!action_queue::IsNativePruneResumeCandidate(idle, before, after)) return false; // The game's own engage entry, so faster recovery sees this attempt like any other. If it throws, the binding // contains the exception and reports it, but the result reads as Success. // TODO(tashcan): let the binding tell a contained exception apart, so a throw is not taken for an engage. const auto result = manager->TryPlanPathAndEngageTarget(fleet, queue); - spdlog::info("[ThinQueueProtection] resumed fleet {} after the {} pruned it: head {} -> {} of {}, {}", - confirmed.player_fleet_id, source, before.head_target_id, confirmed.head_target_id, confirmed.count, - action_queue::EngageResultName(static_cast(result))); + spdlog::info("[ThinQueueProtection] resumed fleet {} after the {} pruned it: head {} -> {} of {}, engage result {}", + after.player_fleet_id, source, before.Head(), after.Head(), after.targets.size(), + std::to_underlying(result)); return result == EngageResult::Success; } @@ -298,7 +248,7 @@ void ActionQueueManager_OnFleetsDisposedEventHandler_Hook(auto original, ActionQ original(_this, fleets); for (const auto target : destroyed) { const auto queue = SnapshotOf(FindQueueWithHead(_this, target)); - if (!action_queue::ShouldProcessDestroyedHead(true, true, target, queue)) + if (!action_queue::ShouldProcessDestroyedHead(target, queue)) continue; _this->ProcessQueue(target, false); spdlog::info("[ThinQueueProtection] removed destroyed target {} from the head of fleet {}'s queue", target, @@ -308,7 +258,8 @@ void ActionQueueManager_OnFleetsDisposedEventHandler_Hook(auto original, ActionQ // --- Queue address guard --- -// Set while the game handles a player-state event, the only time a queue is cleared over a misreported address. +// Set while the game handles a player-state event, the only time a queue is cleared over a misreported address. Only +// set while the fixes are on, so the hooks that read it need no setting check of their own. thread_local bool in_player_state_event = false; // What the queue's address check has seen so far: the fleet's own deployment, and the target being compared. @@ -319,7 +270,6 @@ struct AddressCheck { FleetDeployedData* own_deployment{}; FleetDeployedData* target{}; }; -// Set only while the fixes are on, so the hooks that read it need no setting check of their own. thread_local AddressCheck* current_address_check = nullptr; action_queue::Address AddressOf(NodeAddress* address) @@ -331,21 +281,6 @@ action_queue::Address AddressOf(NodeAddress* address) return value; } -// The fleet's own slot holds its queue, and every queued action names a target. -bool OwnsNonemptyQueue(ActionQueueManager* manager, FleetPlayerData* fleet, int64_t fleet_id) -{ - auto* queue = il2cpp::At(manager->battleQueue(), fleet->Index()); - auto* actions = fleet_id && queue && queue->PlayerFleetId() == fleet_id ? queue->actionQueue() : nullptr; - return actions && actions->Count() > 0 - && std::ranges::all_of(*actions, [](QueueableAction* action) { return action && action->FleetId(); }); -} - -bool IsOwnDeployment(FleetDeployedData* deployment, int64_t fleet_id) -{ - auto* model = deployment->deploymentFleet(); - return deployment->localPlayerFleet() && model && model->FleetId() == fleet_id; -} - void ActionQueueManager_OnPlayerFleetStateChangedEventHandler_Hook(auto original, ActionQueueManager* _this, il2cpp::List* fleets) { @@ -358,12 +293,10 @@ void ActionQueueManager_OnPlayerFleetStateChangedEventHandler_Hook(auto original bool ActionQueueManager_DoesFleetQueueContainMismatchedAddressTargets_Hook(auto original, ActionQueueManager* _this, FleetPlayerData* fleet) { - if (!QueueFixesEnabled() || !in_player_state_event || !fleet) + if (!in_player_state_event || !fleet) return original(_this, fleet); - const auto fleet_id = fleet->Id(); - if (!OwnsNonemptyQueue(_this, fleet, fleet_id)) - return original(_this, fleet); - AddressCheck check{fleet, fleet_id}; + // The check walks this fleet's queue, so an empty one never looks up a deployment. + AddressCheck check{fleet, fleet->Id()}; QueueCallScope scope(current_address_check, &check); return original(_this, fleet); } @@ -379,17 +312,13 @@ NodeAddress* FleetPlayerData_get_Address_Hook(auto original, FleetPlayerData* _t auto* own = check->own_deployment; auto* deployed = own->address(); const action_queue::AddressEvidence evidence{ - .enabled = true, - .player_state_event = in_player_state_event, - .owned_queue = true, - .owned_deployment = IsOwnDeployment(own, check->fleet_id), - .planning_recall = own->IsPlanningRecallCourse(), - .player_state = static_cast(_this->CurrentState()), - .deployed_state = static_cast(own->CurrentState()), - .removal_reason = static_cast(own->RemovalReason()), - .reported = AddressOf(reported), - .deployed = AddressOf(deployed), - .target = AddressOf(check->target->address()), + .planning_recall = own->IsPlanningRecallCourse(), + .player_state = static_cast(_this->CurrentState()), + .deployed_state = static_cast(own->CurrentState()), + .removal_reason = static_cast(own->RemovalReason()), + .reported = AddressOf(reported), + .deployed = AddressOf(deployed), + .target = AddressOf(check->target->address()), }; if (!action_queue::UseDeployedAddress(evidence)) return reported; @@ -423,8 +352,6 @@ bool InstallActionQueueRecovery() ActionQueueManager_ShouldRetryFailedSetCourse_Hook); IL2CPP_STAGE(hooks, Manager::Method("OnSetCourseResponseEventHandler"), ActionQueueManager_OnSetCourseResponseEventHandler_Hook); - IL2CPP_STAGE(hooks, Manager::Method("StopWatchdogAndClearAllQueues"), - ActionQueueManager_StopWatchdogAndClearAllQueues_Hook); return hooks.Commit(); } @@ -459,9 +386,6 @@ bool InstallQueueAddressGuard() } } // namespace -void ResetQueueRecoveryTracking() -{ ClearRequests(); } - // Recovery and thin protection run on Windows and both Mac architectures: the host ABI passes the 24-byte // SetCourseResponse as the game does, and the method lookups reject a client whose managed layout differs. const Feature kActionQueueRecovery{"ActionQueueRecovery", &Config::Patches::actionqueuerecoveryhooks, diff --git a/src/mod/features/action_queue.h b/src/mod/features/action_queue.h index be38c9e07..ca4eb6b7b 100644 --- a/src/mod/features/action_queue.h +++ b/src/mod/features/action_queue.h @@ -5,16 +5,12 @@ #include #include -#include #include #include -#include -#include +#include namespace action_queue { -using Clock = std::chrono::steady_clock; - // --- Faster queue recovery --- // // A target removed while its course request is outstanding can make the failed response retry nothing, which leaves @@ -30,90 +26,60 @@ struct RetryState { bool engaging{}, contains_target{}; }; -// The pending-target field describes the last successful response, not necessarily the outstanding request, so -// it is deliberately not an input. -inline bool CanAdvance(const RetryState& state, std::int64_t failed_fleet, std::int64_t failed_target, - bool outstanding_at_entry, bool same_queue, bool absent_from_all_queues) +// The queue the failure belongs to has moved past the failed target and is idle. The pending-target field describes +// the last successful response, not necessarily the outstanding request, so it is deliberately not an input. +inline bool CanAdvance(const RetryState& state, std::int64_t failed_target) { - return state.valid && same_queue && outstanding_at_entry && failed_target != 0 && state.fleet == failed_fleet - && state.count > 0 && state.front != failed_target && !state.engaging && !state.contains_target - && absent_from_all_queues; + return state.valid && failed_target != 0 && state.count > 0 && state.front != failed_target && !state.engaging + && !state.contains_target; } -// The latest engagement attempt per fleet. Queues are held weakly, so a collected queue cannot be confused with a -// new one at the same address. `Handle` is il2cpp::GcHandle in the game: move-only, `Weak(object)`, `Get()`. -// Callers serialize access. -template class Requests +// The latest engagement attempt per dock slot: the fleet, the head it went for, and the queue's attempt time when it +// went. Fleet, target and attempt time together name one attempt, so a stale failure for an older target or attempt, +// or for a queue from an earlier session, matches nothing and leaves the native retry alone. +class Attempts { public: - using Object = decltype(std::declval().Get()); + // FleetsManager's ten dock slots. A fleet in a slot past them keeps the native retry. + static constexpr int kSlots = 10; - // Records an attempt and returns its serial, for Cancel. - std::uint64_t Remember(Object queue, const RetryState& state, Clock::time_point now) + // An attempt without a readable head still clears the slot, so an older attempt cannot answer for it. + void Remember(int slot, const RetryState& state) { - // Reuse this fleet's record, else the oldest one; expired records are dropped on the way. - auto* slot = &records_.front(); - for (auto& record : records_) { - if (record.handle && (now - record.started >= kLifetime || !record.handle.Get())) - record = {}; - if (record.fleet == state.fleet) { - slot = &record; - break; - } - if (record.started < slot->started) - slot = &record; - } - // An attempt without a readable head still clears the slot, so an older attempt cannot answer for it. - *slot = {}; - const auto serial = ++serial_; + if (slot < 0 || slot >= kSlots) + return; + attempts_[slot] = {}; if (state.valid && state.count > 0 && state.front != 0) - *slot = {Handle::Weak(queue), state.fleet, state.front, state.attempt, now, serial}; - return serial; + attempts_[slot] = {state.fleet, state.front, state.attempt}; } - // An attempt that did not dispatch must not authorize a later failure response. Only that attempt's own record - // goes, so an older reentrant call cannot cancel a newer one. - void Cancel(std::uint64_t serial) + // An attempt that did not dispatch must not authorize a later failure response. + void Forget(int slot) { - for (auto& record : records_) - if (record.serial == serial) - record = {}; + if (slot >= 0 && slot < kSlots) + attempts_[slot] = {}; } - // True once for a failure that answers the remembered attempt of the same live queue. - bool Consume(Object queue, const RetryState& state, std::int64_t target, Clock::time_point now) + // True once for a failure that answers the remembered attempt of the same fleet. + bool Consume(const RetryState& state, std::int64_t target) { - for (auto& record : records_) { - if (record.fleet != state.fleet) + for (auto& attempt : attempts_) { + if (!attempt.fleet || attempt.fleet != state.fleet) continue; - if (!record.handle || now - record.started >= kLifetime || !record.handle.Get()) { - record = {}; + if (attempt.target != target || attempt.attempt != state.attempt) return false; - } - if (record.handle.Get() != queue || record.target != target || record.attempt != state.attempt) - return false; - record = {}; + attempt = {}; return true; } return false; } - void Clear() - { records_ = {}; } - private: - struct Record { - Handle handle{}; - std::int64_t fleet{}, target{}; - float attempt{}; - Clock::time_point started{}; - std::uint64_t serial{}; + struct Attempt { + std::int64_t fleet{}, target{}; + float attempt{}; }; - // A response later than this belongs to some other attempt. - static constexpr auto kLifetime = std::chrono::seconds(30); - // One per dock slot, with room to spare. - std::array records_{}; - std::uint64_t serial_{}; + std::array attempts_{}; }; // --- Thin queue protection --- @@ -121,94 +87,43 @@ template class Requests // The native planner and watchdog can drop a prefix of targets and then leave the fleet idle with the rest still // queued. When that is exactly what happened, the fleet is asked to engage its new head. -// Digit.Prime.Combat.EngageResult. -constexpr int kEngageSuccess = 0; -constexpr int kEngageSkipTarget = 1; -constexpr int kEngageStop = 2; - -constexpr std::string_view EngageResultName(int result) -{ - switch (result) { - case kEngageSuccess: - return "success"; - case kEngageSkipTarget: - return "skip-target"; - case kEngageStop: - return "stop"; - default: - return result < 0 ? "not-attempted" : "unknown"; - } -} - -// A queue's targets and engagement latches. Only the first eight targets are captured; a longer queue is marked -// truncated and never treated as evidence. +// A queue's targets and engagement latches. struct QueueSnapshot { - bool present = false; - std::int64_t player_fleet_id = 0; - int count = -1; - std::int64_t head_target_id = 0; - std::array target_ids{}; - int captured_target_count = 0; - bool targets_truncated = false; - bool is_engaging = false; - std::int64_t last_engaged_target_id = 0; - std::int64_t pending_target_id = 0; - - constexpr std::span Targets() const - { return std::span(target_ids).first(static_cast(captured_target_count)); } + bool present = false; + std::int64_t player_fleet_id = 0; + std::vector targets; + bool is_engaging = false; + std::int64_t last_engaged_target_id = 0; + std::int64_t pending_target_id = 0; + + std::int64_t Head() const + { return targets.empty() ? 0 : targets.front(); } }; // A destroyed target the native disposal left at the head of an idle queue. -constexpr bool ShouldProcessDestroyedHead(bool enabled, bool target_destroyed, std::int64_t target_id, - const QueueSnapshot& after_native) +inline bool ShouldProcessDestroyedHead(std::int64_t target_id, const QueueSnapshot& after_native) { - return enabled && target_destroyed && target_id != 0 && after_native.present && after_native.count > 0 - && after_native.head_target_id == target_id && !after_native.is_engaging + return target_id != 0 && after_native.present && after_native.Head() == target_id && !after_native.is_engaging && after_native.last_engaged_target_id != target_id && after_native.pending_target_id != target_id; } // The native call removed an exact prefix and left the same fleet idle with an unchanged remainder, and neither // latch names a target that is still queued. -constexpr bool IsNativePruneResumeCandidate(bool enabled, bool player_fleet_idle, const QueueSnapshot& before_native, - const QueueSnapshot& after_native) +inline bool IsNativePruneResumeCandidate(bool player_fleet_idle, const QueueSnapshot& before_native, + const QueueSnapshot& after_native) { - if (!enabled || !player_fleet_idle || !before_native.present || !after_native.present - || before_native.player_fleet_id == 0 || before_native.player_fleet_id != after_native.player_fleet_id - || before_native.count <= 0 || after_native.count <= 0 || after_native.count >= before_native.count - || before_native.head_target_id == 0 || after_native.head_target_id == 0 - || after_native.head_target_id == before_native.head_target_id || after_native.is_engaging - || before_native.targets_truncated || after_native.targets_truncated - || before_native.captured_target_count != before_native.count - || after_native.captured_target_count != after_native.count) + const auto before = std::span(before_native.targets); + const auto after = std::span(after_native.targets); + if (!player_fleet_idle || !before_native.present || !after_native.present || before_native.player_fleet_id == 0 + || before_native.player_fleet_id != after_native.player_fleet_id || after.empty() || after.size() >= before.size() + || after.front() == before.front() || after_native.is_engaging) return false; // A latch that names nothing, or only a target the native call removed, is stale rather than live. - const auto removed = - before_native.Targets().first(static_cast(before_native.count - after_native.count)); - const auto stale = [&](std::int64_t target) { return target <= 0 || std::ranges::contains(removed, target); }; - return std::ranges::equal(after_native.Targets(), before_native.Targets().subspan(removed.size())) - && stale(after_native.last_engaged_target_id) && stale(after_native.pending_target_id); -} - -// The queue read again just before resuming still matches what the decision was made on. -constexpr bool IsStableResumePostcondition(const QueueSnapshot& expected, const QueueSnapshot& confirmed) -{ - if (!expected.present || !confirmed.present || expected.player_fleet_id == 0 - || confirmed.player_fleet_id != expected.player_fleet_id || expected.count <= 0 - || confirmed.count != expected.count || expected.head_target_id == 0 - || confirmed.head_target_id != expected.head_target_id || expected.targets_truncated - || confirmed.targets_truncated || expected.captured_target_count != expected.count - || confirmed.captured_target_count != confirmed.count || confirmed.is_engaging || expected.is_engaging - || confirmed.last_engaged_target_id != expected.last_engaged_target_id - || confirmed.pending_target_id != expected.pending_target_id) - return false; - - // Both queues are fully captured by now, so Targets() is the whole queue. - const auto queued = [&](std::int64_t target) { - return target > 0 && std::ranges::contains(confirmed.Targets(), target); - }; - return std::ranges::equal(confirmed.Targets(), expected.Targets()) && !queued(confirmed.last_engaged_target_id) - && !queued(confirmed.pending_target_id); + const auto removed = before.first(before.size() - after.size()); + const auto stale = [&](std::int64_t target) { return target <= 0 || std::ranges::contains(removed, target); }; + return std::ranges::equal(after, before.subspan(removed.size())) && stale(after_native.last_engaged_target_id) + && stale(after_native.pending_target_id); } // --- Queue address guard --- @@ -227,8 +142,9 @@ struct Address { constexpr int kFleetIdleInSpace = 1, kFleetBattling = 64, kFleetImpulsing = 512; constexpr int kDeployedIdle = 0, kDeployedMoving = 1, kDeployedBattling = 3, kDeployedPreBattle = 6; +// What the queue's address check saw for the fleet, its own deployment, and the target being compared. struct AddressEvidence { - bool enabled{}, player_state_event{}, owned_queue{}, owned_deployment{}, planning_recall{}; + bool planning_recall{}; int player_state{-1}, deployed_state{-1}, removal_reason{-1}; Address reported, deployed, target; }; @@ -241,10 +157,9 @@ inline bool UseDeployedAddress(const AddressEvidence& e) e.player_state == kFleetIdleInSpace || e.player_state == kFleetBattling || e.player_state == kFleetImpulsing; const bool deployed_active = e.deployed_state == kDeployedIdle || e.deployed_state == kDeployedMoving || e.deployed_state == kDeployedBattling || e.deployed_state == kDeployedPreBattle; - return e.enabled && e.player_state_event && e.owned_queue && e.owned_deployment && !e.planning_recall - && e.removal_reason == 0 && player_active && deployed_active && e.reported.valid && e.deployed.valid - && e.target.valid && e.reported.planet > 0 && e.deployed.planet == 0 && e.reported.system != e.deployed.system - && e.reported.galaxy == e.deployed.galaxy && e.reported.instance == e.deployed.instance - && e.deployed == e.target; + return !e.planning_recall && e.removal_reason == 0 && player_active && deployed_active && e.reported.valid + && e.deployed.valid && e.target.valid && e.reported.planet > 0 && e.deployed.planet == 0 + && e.reported.system != e.deployed.system && e.reported.galaxy == e.deployed.galaxy + && e.reported.instance == e.deployed.instance && e.deployed == e.target; } } // namespace action_queue diff --git a/src/mod/features/alliance_store.cc b/src/mod/features/alliance_store.cc index 8bdc1b8d9..b0e1a72ed 100644 --- a/src/mod/features/alliance_store.cc +++ b/src/mod/features/alliance_store.cc @@ -1,9 +1,9 @@ -// [patches] alliancegamesstorehooks: the Alliance Games store. Material offers get the native Research actions in -// their tooltips, and the store's currencies and visible offers refresh once its bundles are ready. +// [patches] alliancegamesstorehooks: the Alliance Games store. Its cards get the tooltip actions that inventory +// tooltips have, such as VIEW RESEARCH; once it regenerates its offers, it rebuilds its currency list and binds the +// cards in view. #include "features/features.h" -#include "game/app.h" #include "game/inventory.h" #include @@ -11,503 +11,96 @@ #include #include -#include -#include -#include -#include -#include -#include #include -#include namespace { -using StoreClock = std::chrono::steady_clock; +// --- Tooltip actions --- -// --- Pending work --- - -// The thread that installed the hooks, the game's main thread. The hooks leave calls from any other thread alone. -std::thread::id alliance_store_thread; -bool OnStoreThread() -{ return std::this_thread::get_id() == alliance_store_thread; } - -// The store waiting for its debounced pass. The source is weak, so a closed store is not kept alive, and `epoch` -// changes with it, so a pass that began for one store stops when another takes its place. -struct AllianceStoreWork { - il2cpp::GcHandle source; - uint64_t epoch = 0; - bool pending = false, currency = false, refreshing_currency = false, layout = false; - StoreClock::time_point due{}; -} alliance_store_work; - -// --- Tooltip swap --- - -enum class StoreTooltipUpdate { Unchanged, Upgraded, Restored, Unavailable, Failed }; - -bool IsAllianceStore(il2cpp::Object* object) -{ return object && object->Is(); } - -bool SameStoreItem(InventoryTooltipTriggerWidget* tooltip, TooltipItem* item) -{ return unity::Alive(tooltip) && tooltip->Context() == item; } - -// Native research eligibility of the card's resource. Empty when it cannot be determined. -std::optional ResearchEligible(TooltipItem* item) +// A tooltip widget that has action locale assets: `tooltip` itself, or the first loaded one. +InventoryTooltipTriggerWidget* StoreActionLocaleSource(InventoryTooltipTriggerWidget* tooltip) { - const auto id = item->ResourceId(); - if (id <= 0) - return std::nullopt; - auto* manager = Hub::Research(); - return manager ? manager->IsResourceRequiredByVisibleResearches(id) : std::nullopt; -} - -// A loaded tooltip widget that carries native research actions, whose locale settings the store cards borrow. -InventoryTooltipTriggerWidget* ResearchTemplate(il2cpp::GcRoots& roots) -{ - auto* found = roots.Keep(unity::FindObjectsOfTypeAll()); - if (!found) - return nullptr; - for (auto* widget : *found) - if (widget && widget->Is() && widget->tooltipActionTrigger() - && widget->searchActionLocaleSetting()) - return roots.Keep(widget); + if (tooltip->searchActionLocaleSetting()) + return tooltip; + if (auto* widgets = unity::FindObjectsOfTypeAll()) + for (auto* widget : *widgets) + if (widget && widget->tooltipActionTrigger() && widget->searchActionLocaleSetting()) + return widget; + static bool missing_logged = false; + if (!std::exchange(missing_logged, true)) + spdlog::warn("[AllianceGamesStore] no loaded tooltip has action texts; store cards stay without actions"); return nullptr; } -// Weak component identity survives pooled reuse without retaining a scene. Only the original serialized assets stay -// rooted while our replacement is bound. -struct OwnedStoreTooltip { - il2cpp::GcHandle widget, plain, action, store; - il2cpp::GcHandle search, locale, settings; - bool bound = false, plain_enabled = true, retiring = false; - uint64_t generation = 0; -}; -std::vector> alliance_store_tooltips; - -std::shared_ptr FindOwnedTooltip(InventoryTooltipTriggerWidget* tooltip) -{ - for (auto& entry : alliance_store_tooltips) - if (entry->widget.Get() == tooltip) - return entry; - return nullptr; -} - -void ReleaseStoreAssets(OwnedStoreTooltip& entry) -{ - entry.bound = false; - entry.search.Reset(); - entry.locale.Reset(); - entry.settings.Reset(); -} - -bool RestoreStoreTooltip(OwnedStoreTooltip& entry) -{ - if (!entry.bound) - return true; - if (entry.retiring) - return false; - ++entry.generation; // Any in-flight setup loses publication authority. - entry.retiring = true; - struct ClearRetiring { - bool& value; - ~ClearRetiring() - { value = false; } - } clear{entry.retiring}; - il2cpp::GcRoots roots; - auto* widget = roots.Keep(entry.widget.Get()); - auto* plain = roots.Keep(entry.plain.Get()); - auto* action = roots.Keep(entry.action.Get()); - if (!roots.healthy() || !widget || !plain || !action) - return false; - if (!unity::Alive(widget)) { - ReleaseStoreAssets(entry); - return true; - } - auto* trigger = widget->tooltipTrigger(); - if ((trigger != plain && trigger != action) - || (widget->tooltipActionTrigger() && widget->tooltipActionTrigger() != action)) { - // A new native owner replaced our arrangement. Disable only our component; never overwrite the replacement owner's - // fields or enabled state. - (void)action->TrySetEnabled(false); - ReleaseStoreAssets(entry); - return true; - } - bool ok = action->TrySetEnabled(false); - widget->SetTooltipTrigger(plain); - widget->SetTooltipActionTrigger(nullptr); - widget->SetSearchActionLocaleSetting(entry.search.Get()); - widget->SetActionLocaleSetting(entry.locale.Get()); - widget->SetSettings(entry.settings.Get()); - ok &= widget->UpdateTooltipDescription(); - ok &= plain->TrySetEnabled(entry.plain_enabled); - if (ok) - ReleaseStoreAssets(entry); - return ok; -} - -StoreTooltipUpdate ReconcileStoreTooltip(InventoryTooltipTriggerWidget* tooltip, InventoryTooltipTriggerWidget*& donor, - il2cpp::GcRoots& pass_roots) +// Store cards are built with a plain tooltip trigger and no action assets, so their widget has nothing to put actions +// on. The first time a card shows in the Alliance Games store, it gets an action trigger with the plain one's +// appearance and borrowed assets. From then on the widget's own Refresh picks the actions on every bind, with the +// game's eligibility checks, so nothing here tracks cards. +void BundleDataWidget_OnDidBindContext_Hook(auto original, BundleDataWidget* _this) { - il2cpp::GcRoots roots; - if (!roots.Keep(tooltip)) - return StoreTooltipUpdate::Unavailable; - const auto epoch = alliance_store_work.epoch; - auto* store = roots.Keep(alliance_store_work.source.Get()); - auto* item = unity::Alive(tooltip) ? roots.Keep(tooltip->Context()) : nullptr; - auto same_item = [&] { - if (!store || alliance_store_work.epoch != epoch || alliance_store_work.source.Get() != store) - return false; - return unity::Alive(store) && SameStoreItem(tooltip, item) && alliance_store_work.epoch == epoch - && alliance_store_work.source.Get() == store; - }; - auto* plain = item ? roots.Keep(tooltip->PlainTrigger()) : nullptr; - if (!plain) - return StoreTooltipUpdate::Unchanged; - auto* old_action = roots.Keep(tooltip->tooltipActionTrigger()); - auto* old_trigger = roots.Keep(tooltip->tooltipTrigger()); - auto owned = FindOwnedTooltip(tooltip); - // Existing native action components and unrelated arrangements remain native. - if (!roots.healthy() || (old_action && (!owned || owned->action.Get() != old_action)) - || (old_action ? old_trigger != old_action : old_trigger != plain)) - return StoreTooltipUpdate::Unchanged; - const auto eligible = ResearchEligible(item); - if (!eligible || !same_item()) - return StoreTooltipUpdate::Unavailable; - if (!*eligible) - return owned && owned->bound - ? (RestoreStoreTooltip(*owned) ? StoreTooltipUpdate::Restored : StoreTooltipUpdate::Failed) - : StoreTooltipUpdate::Unchanged; - if (!donor) - donor = ResearchTemplate(pass_roots); - if (!donor) - return StoreTooltipUpdate::Unavailable; - auto* search = roots.Keep(donor->searchActionLocaleSetting()); - auto* locale = roots.Keep(donor->actionLocaleSetting()); - auto* settings = roots.Keep(donor->settings()); - auto* loader = roots.Keep(plain->loader()); - const auto plain_enabled = plain->TryEnabled(); - if (!roots.healthy() || !search || !loader || !plain_enabled || !same_item()) - return StoreTooltipUpdate::Unavailable; - // A failed lookup must not add a second component. - const auto existing = tooltip->ActionTrigger(); - if (!existing) - return StoreTooltipUpdate::Unavailable; - auto* action = roots.Keep(*existing); - // Reuse only the disabled component this feature previously added. - if (action && (!owned || owned->action.Get() != action)) - return StoreTooltipUpdate::Unchanged; - if (!action) - action = roots.Keep(tooltip->AddActionTrigger()); + original(_this); + auto* list = _this->scrollerController(); + auto* tooltip = _this->invTooltip(); + if (!list || !list->Is() || !tooltip || tooltip->tooltipActionTrigger()) + return; + auto* plain = tooltip->tooltipTrigger(); + auto* object = tooltip->gameObject(); + auto* source = plain && object ? StoreActionLocaleSource(tooltip) : nullptr; + auto* action = source ? object->AddComponent() : nullptr; if (!action) - return StoreTooltipUpdate::Unavailable; - if (!owned) { - auto entry = std::make_shared(); - entry->widget = il2cpp::GcHandle::Weak(tooltip); - entry->plain = il2cpp::GcHandle::Weak(plain); - entry->action = il2cpp::GcHandle::Weak(action); - if (entry->widget.Get() != tooltip || entry->plain.Get() != plain || entry->action.Get() != action) { - (void)action->TrySetEnabled(false); - return StoreTooltipUpdate::Unavailable; - } - alliance_store_tooltips.push_back(std::move(entry)); - owned = alliance_store_tooltips.back(); - } - const bool previously_bound = owned->bound; - if (!owned->bound) { - auto retain = [](il2cpp::Object* value, il2cpp::GcHandle& handle) { - handle = il2cpp::GcHandle::Strong(value); - return !value || handle.Get() == value; - }; - if (!retain(tooltip->searchActionLocaleSetting(), owned->search) - || !retain(tooltip->actionLocaleSetting(), owned->locale) || !retain(tooltip->settings(), owned->settings)) { - ReleaseStoreAssets(*owned); - (void)action->TrySetEnabled(false); - return StoreTooltipUpdate::Unavailable; - } - owned->store = il2cpp::GcHandle::Weak(store); - if (owned->store.Get() != store) { - ReleaseStoreAssets(*owned); - (void)action->TrySetEnabled(false); - return StoreTooltipUpdate::Unavailable; - } - owned->plain_enabled = *plain_enabled; - owned->bound = true; - } - const auto generation = ++owned->generation; - auto current = [&] { return same_item() && owned->bound && !owned->retiring && owned->generation == generation; }; - struct Rollback { - std::shared_ptr entry; - bool committed = false; - ~Rollback() - { - if (!committed) - (void)RestoreStoreTooltip(*entry); - } - } rollback{owned}; - - if (!action->TrySetEnabled(false)) - return StoreTooltipUpdate::Failed; + return; action->CopyAppearance(plain); - if (!current()) - return StoreTooltipUpdate::Failed; - tooltip->SetSearchActionLocaleSetting(search); - tooltip->SetActionLocaleSetting(locale); - tooltip->SetSettings(settings); - tooltip->SetTooltipTrigger(action); - tooltip->SetTooltipActionTrigger(action); - if (!tooltip->UpdateTooltipDescription() || !current() || !tooltip->SetupActionTooltipTrigger() || !current() - || !plain->TrySetEnabled(false) || !action->TrySetEnabled(true) || !current()) - return StoreTooltipUpdate::Failed; - const auto enabled = action->TryEnabled(); - if (!current() || !enabled || !*enabled || tooltip->tooltipTrigger() != action - || tooltip->tooltipActionTrigger() != action) - return StoreTooltipUpdate::Failed; - rollback.committed = true; - return previously_bound ? StoreTooltipUpdate::Unchanged : StoreTooltipUpdate::Upgraded; + plain->SetEnabled(false); + tooltip->UseActionTrigger(action, source); + // This bind already ran the widget's own setup on the plain trigger. + tooltip->Refresh(); + tooltip->SetupActionTooltipTrigger(); } -// --- Hooks --- +// --- Regeneration --- -void QueueStoreWork(AllianceGamesStoreScrollerViewController* source, bool currency) -{ - auto& work = alliance_store_work; - if (work.source.Get() != source) { - work.source = il2cpp::GcHandle::Weak(source); - ++work.epoch; - } - if (work.source.Get() != source) - return; - // Debounce through separate-frame native card spawning. - work.due = StoreClock::now() + std::chrono::milliseconds(250); - work.pending = true; - work.currency |= currency && !work.refreshing_currency; - work.layout |= currency && !work.refreshing_currency; -} - -void AllianceGamesStoreScrollerViewController_OnDidBindCanvasContext_Hook( - auto original, AllianceGamesStoreScrollerViewController* _this) -{ - if (!OnStoreThread()) { - original(_this); - return; - } - original(_this); - std::erase_if(alliance_store_tooltips, [](auto& entry) { return !entry->widget.Get(); }); - if (IsAllianceStore(_this)) - QueueStoreWork(_this, true); -} - -void AllianceGamesStoreScrollerViewController_OnAboutToReleaseCanvasContext_Hook( - auto original, AllianceGamesStoreScrollerViewController* _this) -{ - if (!OnStoreThread()) { - original(_this); - return; - } - auto& work = alliance_store_work; - if (work.source.Get() == _this) { - work.source.Reset(); - work.pending = work.currency = work.layout = false; - ++work.epoch; - } - // Native restoration can reenter bind/pruning. Keep stable entry custody and invalidate the source before the first - // such callback. - const auto entries = alliance_store_tooltips; - for (auto& entry : entries) - if (entry->store.Get() == _this) - (void)RestoreStoreTooltip(*entry); - original(_this); -} +// The store whose regenerated cards may still be spawning. The next regeneration replaces it. +il2cpp::GcHandle alliance_store_spawning; void ShopListScrollerViewController_RegenerateSortedBundles_Hook(auto original, ShopListScrollerViewController* _this, bool instant) { - if (!OnStoreThread()) { - original(_this, instant); - return; - } original(_this, instant); - if (IsAllianceStore(_this) && !alliance_store_work.refreshing_currency) - QueueStoreWork(static_cast(_this), true); + if (_this->Is()) + alliance_store_spawning = il2cpp::GcHandle::Weak(_this); } -void BundleDataWidget_OnAboutToReleaseContext_Hook(auto original, BundleDataWidget* _this) -{ - if (!OnStoreThread()) { - original(_this); - return; - } - if (auto entry = FindOwnedTooltip(_this->invTooltip())) - (void)RestoreStoreTooltip(*entry); - original(_this); -} - -void QueueStoreCard(BundleDataWidget* card) -{ - // Native OnDidBindContext sets this AFTER SetupTooltip. Never use the RefreshBundle argument or a pre-bind owner as - // current-card identity. - auto* owner = card->scrollerController(); - if (owner && alliance_store_work.source.Get() == owner && IsAllianceStore(owner)) - QueueStoreWork(static_cast(owner), false); -} -void BundleDataWidget_RefreshBundle_Hook(auto original, BundleDataWidget* _this, Bundle* bundle) -{ - if (!OnStoreThread()) { - original(_this, bundle); - return; - } - original(_this, bundle); - QueueStoreCard(_this); -} -void BundleDataWidget_OnDidBindContext_Hook(auto original, BundleDataWidget* _this) -{ - if (!OnStoreThread()) { - original(_this); - return; - } - original(_this); - QueueStoreCard(_this); -} - -// --- Frame work --- - -std::string StoreCountText(const auto& value) -{ return value ? std::to_string(*value) : "unavailable"; } - void AllianceGamesStoreFrame() { - if (!OnStoreThread()) + auto* store = alliance_store_spawning.Get(); + auto* scroller = store ? store->smartScroller() : nullptr; + if (!store || (scroller && scroller->Spawning())) return; - auto& work = alliance_store_work; - if (!work.pending || StoreClock::now() < work.due) + alliance_store_spawning.Reset(); + if (!unity::Alive(store)) return; - const bool currency = std::exchange(work.currency, false); - const bool layout = std::exchange(work.layout, false); - work.pending = false; - const auto epoch = work.epoch; - il2cpp::GcRoots roots; - auto* source = roots.Keep(work.source.Get()); - auto* context = source && unity::Alive(source) ? roots.Keep(source->AllianceGamesContext()) : nullptr; - if (!context || !context->IsShopViewActive()) - return; - if (work.epoch != epoch || work.source.Get() != source) - return; - auto* scroller = roots.Keep(source->smartScroller()); - auto* smart = scroller ? scroller->As() : nullptr; - if (smart && (smart->regeneratingContent() || smart->spawnerCoroutine() || smart->dataHeldWhilstInitializing())) { - work.pending = true; - work.currency |= currency; - work.layout |= layout; - work.due = StoreClock::now() + std::chrono::milliseconds(250); - return; - } - bool position_notified = false; - if (layout && smart) { - const auto position = smart->NormalizedPosition(); - if (position && unity::Alive(smart) && work.epoch == epoch && work.source.Get() == source - && source->smartScroller() == smart) { - position_notified = smart->OnValueChangedCallback(*position); - } - } - if (work.epoch != epoch || work.source.Get() != source || source->smartScroller() != scroller) - return; - auto* pool = smart ? roots.Keep(smart->spawnedItems()) : nullptr; - const auto count = pool ? smart->SpawnedCount() : std::nullopt; - if (!count || *count < 0) - return; - int upgraded = 0, restored = 0, unavailable = 0, failed = 0; - InventoryTooltipTriggerWidget* donor = nullptr; - for (int32_t index = 0; index < *count; ++index) { - if (work.epoch != epoch || work.source.Get() != source) - return; - il2cpp::GcRoots card_roots; - auto* card = card_roots.Keep((*pool)[index]); - if (!card || !unity::Alive(card)) - continue; - auto* tooltip = card_roots.Keep(SmartScrollerBase::CardTooltip(card)); - switch (tooltip ? ReconcileStoreTooltip(tooltip, donor, roots) : StoreTooltipUpdate::Unchanged) { - case StoreTooltipUpdate::Upgraded: - ++upgraded; - break; - case StoreTooltipUpdate::Restored: - ++restored; - break; - case StoreTooltipUpdate::Unavailable: - ++unavailable; - break; - case StoreTooltipUpdate::Failed: - ++failed; - break; - default: - break; - } - } - bool refreshed = false; - std::optional currency_before, currency_after; - // Native bundle-derived discovery, not a fixed list of currency IDs. An empty/loading store waits for its real - // data/regeneration event. - const auto fetching = context->IsFetchingBundlesInProgress(); - if (currency && work.epoch == epoch && work.source.Get() == source && fetching && !*fetching - && context->BundlesCount() > 0) { - work.refreshing_currency = true; - struct Reset { - bool& value; - ~Reset() - { value = false; } - } reset{work.refreshing_currency}; - currency_before = context->CurrencyCount(); - if (source->AllianceGamesContext() == context && unity::Alive(source) && work.epoch == epoch - && work.source.Get() == source) { - refreshed = context->UpdateStoreCurrency(); - currency_after = context->CurrencyCount(); - } - } - if (layout) - spdlog::info("[AllianceGamesStore] settled layout notification={} (position unchanged)", position_notified); - const auto visible = smart ? smart->VisibleItemsCount() : std::nullopt; - const auto position = smart ? smart->ScrollPosition() : std::nullopt; - spdlog::info("[AllianceGamesStore] pool={} upgraded={} restored={} unavailable={} failed={} currency_refresh={} " - "currencies={}->{} visible={} position={}", - *count, upgraded, restored, unavailable, failed, refreshed, StoreCountText(currency_before), - StoreCountText(currency_after), StoreCountText(visible), StoreCountText(position)); + // The store's context handler is an empty override, so nothing rebuilds the currency list that the section context + // made before the bundles arrived. + auto* context = store->AllianceGamesContext(); + if (context && context->HasBundles()) + context->UpdateStoreCurrency(); + // Cards spawned after the regeneration stay unbound until the list scrolls. + if (scroller) + scroller->Rebind(); } bool InstallAllianceGamesStore() { - using Store = AllianceGamesStoreScrollerViewController::Class; - using Shop = ShopListScrollerViewController::Class; - using Card = BundleDataWidget::Class; - const std::array targets{ - Store::Method("OnDidBindCanvasContext"), Store::Method("OnAboutToReleaseCanvasContext"), - Shop::Method("RegenerateSortedBundles"), Card::Method("RefreshBundle"), - Card::Method("OnAboutToReleaseContext"), Card::Method("OnDidBindContext")}; - const std::array owners{Store::Raw(), Store::Raw(), Shop::Raw(), Card::Raw(), Card::Raw(), Card::Raw()}; - // Each target must be the class's own method: the lookup falls back to a base class's, and a detour there would run - // for every subclass. Nor may two share the body SPUD patches, or one hook would also run for the other's receiver. - std::array bodies{}; - for (size_t i = 0; i < targets.size(); ++i) { - if (!targets[i]) - continue; // Reported by the lookup, and keeps the set out. - if (targets[i].Info()->klass != owners[i]) { - spdlog::warn("[AllianceGamesStore] unavailable: {} is inherited", targets[i].Name()); - return false; - } - bodies[i] = il2cpp::DetourTarget(targets[i]); - for (size_t j = 0; j < i; ++j) - if (bodies[i] == bodies[j]) { - spdlog::warn("[AllianceGamesStore] unavailable: shared hook target"); - return false; - } + auto bind = BundleDataWidget::Class::Method("OnDidBindContext"); + // The lookup falls back to Widget`1's, which every widget shares. + if (bind && bind.Info()->klass != BundleDataWidget::Class::Raw()) { + spdlog::warn("[AllianceGamesStore] BundleDataWidget no longer overrides OnDidBindContext"); + return false; } - alliance_store_thread = std::this_thread::get_id(); - // One transaction: every hook or none. The frame work runs from the frame tick; without the tick the hooks only - // record pending work. il2cpp::HookSet hooks("AllianceGamesStore"); - IL2CPP_STAGE(hooks, targets[0], AllianceGamesStoreScrollerViewController_OnDidBindCanvasContext_Hook); - IL2CPP_STAGE(hooks, targets[1], AllianceGamesStoreScrollerViewController_OnAboutToReleaseCanvasContext_Hook); - IL2CPP_STAGE(hooks, targets[2], ShopListScrollerViewController_RegenerateSortedBundles_Hook); - IL2CPP_STAGE(hooks, targets[3], BundleDataWidget_RefreshBundle_Hook); - IL2CPP_STAGE(hooks, targets[4], BundleDataWidget_OnAboutToReleaseContext_Hook); - IL2CPP_STAGE(hooks, targets[5], BundleDataWidget_OnDidBindContext_Hook); + IL2CPP_STAGE(hooks, bind, BundleDataWidget_OnDidBindContext_Hook); + IL2CPP_STAGE(hooks, ShopListScrollerViewController::Class::Method("RegenerateSortedBundles"), + ShopListScrollerViewController_RegenerateSortedBundles_Hook); return hooks.Commit(); } } // namespace diff --git a/src/mod/features/anomaly_timer.cc b/src/mod/features/anomaly_timer.cc index 5aec8f458..621925bd7 100644 --- a/src/mod/features/anomaly_timer.cc +++ b/src/mod/features/anomaly_timer.cc @@ -14,11 +14,14 @@ #include #include -#include namespace { -using AnomalyClock = std::chrono::steady_clock; +// The countdown label, under a panel parented to the upper-right drawer, so it follows the drawer's scale and +// visibility without moving any native button. Unity destroys it with the drawer; it is then built again. Never +// destroyed: freeing a GC handle in a static destructor can run after the runtime has shut down. +auto& anomaly_label = *new il2cpp::GcHandle; +int64_t anomaly_shown_seconds = -1; // The rounded key-hint background next to the native anomaly button, reused for the panel. unity::Sprite* NativeBackgroundSprite(HudNavigationViewController* nav) @@ -36,11 +39,11 @@ unity::Sprite* NativeBackgroundSprite(HudNavigationViewController* nav) return sprite ? sprite : image->sprite(); } -unity::Image* AddPanelImage(unity::GameObject* object, unity::Color tint, unity::Sprite* sprite) +void AddPanelImage(unity::GameObject* object, unity::Color tint, unity::Sprite* sprite) { auto* image = object->AddComponent(); if (!image) - return nullptr; + return; image->SetColor(tint); image->SetRaycastTarget(false); if (sprite && sprite->pixelsPerUnit() > 0) { @@ -50,195 +53,106 @@ unity::Image* AddPanelImage(unity::GameObject* object, unity::Color tint, unity: // whatever the localized artwork's density is. image->SetPixelsPerUnitMultiplier(100 / sprite->pixelsPerUnit()); } - return image; } -// The countdown panel under the upper-right Help All / Engage / Claim drawer. Parenting to the drawer follows its -// scale and visibility without moving any native button. -class AnomalyTimer +int64_t AnomalyRemainingTicks(HudNavigationViewController* nav) { -public: - void Update() - { - const auto now = AnomalyClock::now(); - if (now < next_update_) - return; - next_update_ = now + std::chrono::milliseconds(250); - if (!Config::Get().graphics.galactic_anomaly_timer) { - DestroyPanel(); - navigation_.Reset(); - return; - } - - auto* nav = Navigation(); - // Remaining ticks of the active anomaly in the viewed system. - const int64_t ticks = nav && nav->isActiveAndEnabled() && nav->CanShowGalacticAnomaly() ? RemainingTicks(nav) : 0; - auto* label = Label(); - if (ticks <= 0) { - if (auto* panel = label ? label->ParentObject() : nullptr) - panel->SetActive(false); - return; - } - if (!label) - label = Create(nav); - if (!label) { - static bool warned = false; - if (!std::exchange(warned, true)) - spdlog::warn("[AnomalyTimer] could not build the countdown panel; leaving the HUD unchanged"); - DestroyPanel(); - next_update_ = now + std::chrono::seconds(5); - return; - } - - // TimeSpan ticks are 100 ns. Round up, so the last second shows as 1s rather than 0s. - const auto seconds = ticks / 10'000'000 + (ticks % 10'000'000 != 0); - if (seconds != shown_seconds_) { - shown_seconds_ = seconds; - const auto text = seconds >= 3600 - ? fmt::format("{}h {:02}m {:02}s", seconds / 3600, seconds / 60 % 60, seconds % 60) - : fmt::format("{:02}m {:02}s", seconds / 60, seconds % 60); - label->SetText(text); - } - if (auto* panel = label->ParentObject()) - panel->SetActive(true); - } - -private: - // The navigation HUD while the system view is open. It has no singleton; look for it on its registered canvas - // whenever the one held is gone or inactive, rather than with FindObjectOfType, which scans every object. The - // search walks every registered canvas, so it runs at most once a second, and a held HUD is kept until a - // different live one turns up. - HudNavigationViewController* Navigation() - { - if (Hub::CurrentSection() != SectionID::Navigation_System) { - DestroyPanel(); - navigation_.Reset(); - return nullptr; - } - auto* held = navigation_.Get(); - if (unity::Alive(held) && held->isActiveAndEnabled()) - return held; - if (const auto now = AnomalyClock::now(); now >= next_discovery_) { - next_discovery_ = now + std::chrono::seconds(1); - auto* replacement = ScreenManager::View(); - if ((replacement && replacement != held) || !unity::Alive(held)) { - DestroyPanel(); - navigation_ = il2cpp::GcHandle::Strong(replacement); - } - } - auto* nav = navigation_.Get(); - return unity::Alive(nav) ? nav : nullptr; - } - - unity::TMP_Text* Label() - { - auto* label = label_.Get(); - return unity::Alive(label) ? label : nullptr; - } - - void DestroyPanel() - { - if (auto* panel = Label() ? Label()->ParentObject() : nullptr) { - panel->SetActive(false); - unity::Destroy(panel); - } - label_.Reset(); - shown_seconds_ = -1; - } - - static int64_t RemainingTicks(HudNavigationViewController* nav) - { - auto* context = nav->CanvasContext(); - auto* address = context ? context->ViewingAddress() : nullptr; - int64_t system = address ? address->System() : -1; - auto* manager = system > 0 ? GalacticAnomaliesManager::Current() : nullptr; - auto* anomaly = manager ? manager->GetSystemGalacticAnomalies(system) : nullptr; - if (!anomaly || !anomaly->IsActive()) - return 0; - auto* timer = anomaly->EndTimeTimerDataContext(); - return timer ? timer->RemainingTime().ticks : 0; - } + auto* context = nav->CanvasContext(); + auto* address = context ? context->ViewingAddress() : nullptr; + int64_t system = address ? address->System() : -1; + auto* manager = system > 0 ? GalacticAnomaliesManager::Current() : nullptr; + auto* anomaly = manager ? manager->GetSystemGalacticAnomalies(system) : nullptr; + if (!anomaly || !anomaly->IsActive()) + return 0; + auto* timer = anomaly->EndTimeTimerDataContext(); + return timer ? timer->RemainingTime().ticks : 0; +} - // Builds the panel: the native rounded sprite twice, with a narrow inset making the border, and a countdown in - // the system-name font. A missing sprite still gives a visible frame. Returns the countdown label, or null. - unity::TMP_Text* Create(HudNavigationViewController* nav) - { - auto* helper = nav->factionInformationHelper(); - auto* name = il2cpp::At(helper ? helper->systemName() : nullptr, 0); - if (!unity::Alive(name)) - return nullptr; - auto* drawer = ScreenManager::View(); - auto* promotion = drawer ? drawer->chestPromotion() : nullptr; - auto* chest = promotion ? promotion->transform() : nullptr; - auto* parent = chest ? chest->parent() : nullptr; - auto* object = name->gameObject(); - auto* source = object ? object->GetComponent() : nullptr; - auto* font = source ? source->font() : nullptr; - if (!parent || !font) - return nullptr; - - il2cpp::GcHandle panel, fill, text; - auto* panel_rect = unity::CreateUiObject("CommunityMod_AnomalyTimer", parent, panel); - if (!panel_rect) - return nullptr; - panel_rect->Place({0.5f, 0}, {0.5f, 0}, {0.5f, 1}, {198, 38}, {0, -40}); - auto* rounded = NativeBackgroundSprite(nav); - if (!AddPanelImage(panel.Get(), {0.64f, 0.34f, 0.46f, 0.85f}, rounded)) { - Abandon(panel); - return nullptr; - } - - auto* fill_rect = unity::CreateUiObject("AnomalyFill", panel_rect, fill); - if (!fill_rect || !AddPanelImage(fill.Get(), {0.06f, 0.10f, 0.14f, 0.96f}, rounded)) { - Abandon(panel); - return nullptr; - } - fill_rect->Place({0.5f, 0.5f}, {0.5f, 0.5f}, {0.5f, 0.5f}, {194, 34}, {0, 0}); - fill.Get()->SetActive(true); - - auto* text_rect = unity::CreateUiObject("Countdown", panel_rect, text); - auto* label = text_rect ? text.Get()->AddComponent() : nullptr; - if (!label) { - Abandon(panel); - return nullptr; - } - text_rect->Place({0.5f, 0.5f}, {0.5f, 0.5f}, {0.5f, 0.5f}, {190, 28}, {0, 0}); - label->SetFont(font); - label->SetFontSize(22); - label->SetAlignment(unity::TMP_Text::Alignment::Center); - label->SetColor({0.95f, 0.83f, 0.89f, 1}); - label->SetRaycastTarget(false); - label->SetEnableWordWrapping(false); - text.Get()->SetActive(true); - // The children are owned by the hierarchy once parented; only the label is kept. - label_ = il2cpp::GcHandle::Strong(label); - return label; - } +// Builds the panel: the native rounded sprite twice, with a narrow inset making the border, and a countdown in the +// system-name font. A missing sprite still gives a visible frame. Null while the drawer or the HUD's font is not up +// yet; the next tick tries again. +unity::TMP_Text* CreateAnomalyPanel(HudNavigationViewController* nav) +{ + auto* helper = nav->factionInformationHelper(); + auto* name = il2cpp::At(helper ? helper->systemName() : nullptr, 0); + if (!unity::Alive(name)) + return nullptr; + auto* drawer = ScreenManager::View(); + auto* promotion = drawer ? drawer->chestPromotion() : nullptr; + auto* chest = promotion ? promotion->transform() : nullptr; + auto* parent = chest ? chest->parent() : nullptr; + auto* object = name->gameObject(); + auto* source = object ? object->GetComponent() : nullptr; + auto* font = source ? source->font() : nullptr; + if (!parent || !font) + return nullptr; - // Destroys a half-built panel. - static void Abandon(il2cpp::GcHandle& panel) - { - if (auto* object = panel.Get(); unity::Alive(object)) - unity::Destroy(object); - } + // The children are owned by the hierarchy once parented; only the label is kept. The objects below are null only + // when a Unity class is missing, which the library reports; a half-built panel then stays inactive. + il2cpp::GcHandle panel, fill, text; + auto* panel_rect = unity::CreateUiObject("CommunityMod_AnomalyTimer", parent, panel); + if (!panel_rect) + return nullptr; + panel_rect->Place({0.5f, 0}, {0.5f, 0}, {0.5f, 1}, {198, 38}, {0, -40}); + auto* rounded = NativeBackgroundSprite(nav); + AddPanelImage(panel.Get(), {0.64f, 0.34f, 0.46f, 0.85f}, rounded); - il2cpp::GcHandle navigation_, label_; - AnomalyClock::time_point next_update_{}, next_discovery_{}; - int64_t shown_seconds_ = -1; -}; + auto* fill_rect = unity::CreateUiObject("AnomalyFill", panel_rect, fill); + if (!fill_rect) + return nullptr; + AddPanelImage(fill.Get(), {0.06f, 0.10f, 0.14f, 0.96f}, rounded); + fill_rect->Place({0.5f, 0.5f}, {0.5f, 0.5f}, {0.5f, 0.5f}, {194, 34}, {0, 0}); + fill.Get()->SetActive(true); -// Leaked on purpose: its GC handles must not be released by static destructors after the runtime is gone. -AnomalyTimer* anomaly_timer = nullptr; + auto* text_rect = unity::CreateUiObject("Countdown", panel_rect, text); + auto* label = text_rect ? text.Get()->AddComponent() : nullptr; + if (!label) + return nullptr; + text_rect->Place({0.5f, 0.5f}, {0.5f, 0.5f}, {0.5f, 0.5f}, {190, 28}, {0, 0}); + label->SetFont(font); + label->SetFontSize(22); + label->SetAlignment(unity::TMP_Text::Alignment::Center); + label->SetColor({0.95f, 0.83f, 0.89f, 1}); + label->SetRaycastTarget(false); + label->SetEnableWordWrapping(false); + text.Get()->SetActive(true); + anomaly_label = il2cpp::GcHandle::Strong(label); + anomaly_shown_seconds = -1; + return label; +} void AnomalyTimerFrame() -{ anomaly_timer->Update(); } - -bool InstallAnomalyTimer() { - anomaly_timer = new AnomalyTimer; - return true; + static Throttle throttle(std::chrono::milliseconds(250)); + if (!throttle.Due()) + return; + + // View only returns a HUD that is up on a visible canvas, so nothing about it needs holding between ticks. + auto* nav = Config::Get().graphics.galactic_anomaly_timer && Hub::CurrentSection() == SectionID::Navigation_System + ? ScreenManager::View() + : nullptr; + const int64_t ticks = nav && nav->CanShowGalacticAnomaly() ? AnomalyRemainingTicks(nav) : 0; + auto* label = anomaly_label.Get(); + if (!unity::Alive(label)) + label = nullptr; + if (ticks <= 0) { + if (auto* panel = label ? label->ParentObject() : nullptr) + panel->SetActive(false); + return; + } + if (!label && !(label = CreateAnomalyPanel(nav))) + return; + + // TimeSpan ticks are 100 ns. Round up, so the last second shows as 1s rather than 0s. + const auto seconds = ticks / 10'000'000 + (ticks % 10'000'000 != 0); + if (seconds != anomaly_shown_seconds) { + anomaly_shown_seconds = seconds; + label->SetText(seconds >= 3600 ? fmt::format("{}h {:02}m {:02}s", seconds / 3600, seconds / 60 % 60, seconds % 60) + : fmt::format("{:02}m {:02}s", seconds / 60, seconds % 60)); + } + if (auto* panel = label->ParentObject()) + panel->SetActive(true); } } // namespace -const Feature kAnomalyTimer{"AnomalyTimer", &Config::Patches::galacticanomalytimerhooks, InstallAnomalyTimer, - AnomalyTimerFrame}; +const Feature kAnomalyTimer{"AnomalyTimer", &Config::Patches::galacticanomalytimerhooks, nullptr, AnomalyTimerFrame}; diff --git a/src/mod/features/camera.cc b/src/mod/features/camera.cc index 6c044eb98..1da7bc3e8 100644 --- a/src/mod/features/camera.cc +++ b/src/mod/features/camera.cc @@ -19,7 +19,6 @@ #include #include -#include #include #include #include @@ -194,16 +193,11 @@ void NavigationZoom_SetDepth_Hook(auto original, NavigationZoom* _this, NodeDept { EnterDepth(original, _this, depth, _this->viewRadius(), depth); } // Planet flat renderables (the 2D planet sprites) are scaled by fr_scale. The game reuses them and asks for them on -// every zoom, so each transform remembers the size it was scaled to and is left alone while it still has it. -// Planets differ in base size, so this is per transform: one shared value made two planets rescale each other. -// -// A backdrop that was just created or reloaded has its authored scale again and is always scaled. The getter is -// hooked, so ScaleAuthoredBackdrop asks for the renderable with authored_backdrop set; backdrop_revision counts -// CreateBackdrop calls so Populate can tell whether one ran. -bool authored_backdrop = false; -uint64_t backdrop_revision = 0; - -void ScaleFlatRenderable(unity::Component* renderable, bool authored) +// every zoom, so each transform remembers the size it was scaled to and is left alone while it has exactly that size. +// Planets differ in base size, so this is per transform: one shared value made two planets rescale each other. A +// backdrop that was just created or reloaded has its authored scale again, which never equals the remembered one +// while fr_scale is not 1, so it is scaled again. +void ScaleFlatRenderable(unity::Component* renderable) { // A system has a handful of planets; past this, entries from earlier systems are dropped. constexpr size_t kRemembered = 256; @@ -216,7 +210,7 @@ void ScaleFlatRenderable(unity::Component* renderable, bool authored) if (!transform) return; const auto scale = transform->localScale(); - if (const auto it = scaled.find(transform); !authored && it != scaled.end() && std::fabs(scale.x - it->second) < 0.1f) + if (const auto it = scaled.find(transform); it != scaled.end() && scale.x == it->second) return; if (scaled.size() >= kRemembered) scaled.clear(); @@ -228,7 +222,7 @@ void ScaleFlatRenderable(unity::Component* renderable, bool authored) unity::Component* PlanetViewUtils_get_FlatRenderable_Hook(auto original, PlanetViewUtils* _this) { auto* renderable = original(_this); - ScaleFlatRenderable(renderable, authored_backdrop); + ScaleFlatRenderable(renderable); return renderable; } @@ -240,56 +234,28 @@ void PlanetViewUtils_CameraZoomedEventHandler_Hook(auto original, PlanetViewUtil _this->FlatRenderable(); } -void ScaleAuthoredBackdrop(PlanetViewUtils* view) -{ - const auto previous = std::exchange(authored_backdrop, true); - view->FlatRenderable(); - authored_backdrop = previous; -} - void PlanetViewUtils_CreateBackdrop_Hook(auto original, PlanetViewUtils* _this, Il2CppObject* backdrop, Il2CppObject* resource, bool using_default) { original(_this, backdrop, resource, using_default); - if (resource) { - ++backdrop_revision; - ScaleAuthoredBackdrop(_this); - } + if (resource) + _this->FlatRenderable(); } +// A cached variation backdrop reloads its authored scale without calling CreateBackdrop. void PlanetViewUtils_Populate_Hook(auto original, PlanetViewUtils* _this, Il2CppObject* system, int64_t node_id, NodeDepth depth) { - auto* previous = _this->popData(); - const auto revision = backdrop_revision; original(_this, system, node_id, depth); - auto* population = _this->popData(); - // A cached variation backdrop reloads its authored scale without calling CreateBackdrop. - if (depth == NodeDepth::SolarSystem && population && population != previous && population->VariationCollection() - && revision == backdrop_revision) - ScaleAuthoredBackdrop(_this); + if (depth == NodeDepth::SolarSystem) + _this->FlatRenderable(); } // --- Panning and rotation --- -// Whether the mod, not the game, moves the camera with the Move* shortcuts. -bool ModMovesCamera() -{ - const auto& config = Config::Get(); - return config.control.hotkeys_enabled && !config.control.use_scopely_hotkeys && !config.ui.disable_move_keys; -} - bool PointerHeld() { return unity::Input::GetMouseButton(0) || unity::Input::touchCount() > 0; } -// The game's own tutorial and modal/text-input gates for keyboard panning. -bool KeyboardPanAllowed() -{ - auto* manager = Hub::Tutorial(); - return manager && manager->IsSceneInputAllowed() && ShortcutsManager::CanUseShortcuts() - && !unity::Key::IsInputFocused(); -} - // Replaces the game's LateUpdate: the keyboard source is the mod's shortcuts, and the pan keeps gliding after the // drag or key is released. Each frame moves the camera once: by the keys while one is held, otherwise by the // glide. @@ -301,11 +267,9 @@ bool NavigationPan_LateUpdate_Hook(auto original, NavigationPan* _this) // Dropping the tracked target before either pan path runs lets the player pan away from it. if (disable_target_camera_lock && _this->trackingPOI()) _this->SetTrackingPOI(nullptr); - // An arrow held for a CTRL-arrow style shortcut must not pan as well, natively or through the mod. - constexpr std::array kArrows{unity::KeyCode::LeftArrow, unity::KeyCode::RightArrow, unity::KeyCode::UpArrow, - unity::KeyCode::DownArrow}; - const bool arrow_owned = std::ranges::any_of(kArrows, Shortcut::Owns); - const bool keyboard = config.control.hotkeys_enabled && !config.ui.disable_move_keys && !arrow_owned; + // An arrow held for a CTRL-arrow shortcut does not pan: Shortcut::Matches keeps the plain move binding waiting + // until the arrow is released. A modified move binding such as CTRL-LEFT still pans. + const bool keyboard = config.control.hotkeys_enabled && !config.ui.disable_move_keys; // With the game's own hotkeys, its LateUpdate pans and glides by itself. The mod still applies its momentum falloff // to that glide, without moving the camera a second time. @@ -319,7 +283,8 @@ bool NavigationPan_LateUpdate_Hook(auto original, NavigationPan* _this) // Never replay its delta as momentum. const bool pointer = PointerHeld() || _this->panningStarted(); bool by_keys = false; - if (keyboard && !native && !blocked && !pointer && KeyboardPanAllowed()) { + // The game's own gate for its shortcuts also covers the tutorial's input lock, modals and a focused text field. + if (keyboard && !native && !blocked && !pointer && ShortcutsManager::CanUseShortcuts()) { // Opposite bindings cancel; normalize diagonals like the native 2D composite. MoveCamera takes a drag, and // dragging left moves the view right, hence the negation. const float x = float(Shortcut::IsPressed(Action::kMoveRight)) - float(Shortcut::IsPressed(Action::kMoveLeft)); @@ -353,9 +318,10 @@ void OrbitFrameProvider_UpdateInputData_Hook(auto original, OrbitFrameProvider* original(_this, primary_camera); unity::Key::Poll(); - auto* sections = Hub::Sections(); - if (!ModMovesCamera() || !sections || sections->CurrentSection() != SectionID::Starbase_Exterior - || unity::Key::IsInputFocused()) + const auto& config = Config::Get(); + auto* sections = Hub::Sections(); + if (!config.control.hotkeys_enabled || config.control.use_scopely_hotkeys || config.ui.disable_move_keys || !sections + || sections->CurrentSection() != SectionID::Starbase_Exterior || unity::Key::IsInputFocused()) return; constexpr float kDegreesPerSecond = 45.0f; diff --git a/src/mod/features/cargo.cc b/src/mod/features/cargo.cc index f5965a4c5..4d49197a0 100644 --- a/src/mod/features/cargo.cc +++ b/src/mod/features/cargo.cc @@ -36,26 +36,11 @@ void ColourTextLocalizer_SetLocalTextParameters_Hook(auto original, ColourTextLo // // TODO: Windows only. Enabling it on macOS needs SetWidgetData's prologue checked against SPUD's detour size. #if _WIN32 -bool IsCargoText(LegacyComparableProgressBar* bar) -{ - auto* label = bar->currentValue_X_of_Y(); - if (!label || !label->IdentifierIs(kCargoIdentifier)) - return false; - auto* context = bar->context(); - if (context && context->klass == CargoProgressData::Raw()) - return true; - static bool reported = false; - if (!reported) { - reported = true; - spdlog::warn("[InstantCargo] the cargo label shows a {}, not cargo; keeping the game's count-up", - context ? il2cpp::ClassName(context->klass) : "null"); - } - return false; -} - void LegacyComparableProgressBar_SetWidgetData_Hook(auto original, LegacyComparableProgressBar* _this) { - if (!Config::Get().ui.instant_cargo_counter || !IsCargoText(_this)) { + // The bar's data decides whether it shows cargo; the X-of-Y label identifier is shared with other bars. + auto* context = _this->context(); + if (!Config::Get().ui.instant_cargo_counter || !context || context->klass != CargoProgressData::Raw()) { original(_this); return; } diff --git a/src/mod/features/features.h b/src/mod/features/features.h index d6cb9daf0..5f838ca41 100644 --- a/src/mod/features/features.h +++ b/src/mod/features/features.h @@ -53,8 +53,6 @@ class Throttle extern const Feature kActionQueueRecovery; extern const Feature kThinQueueProtection; extern const Feature kQueueAddressGuard; -// Forgets the pending recovery attempts when the queue is switched off or on. Never touches the game's queues. -void ResetQueueRecoveryTracking(); // alliance_store.cc extern const Feature kAllianceGamesStore; diff --git a/src/mod/features/fleet_actions.cc b/src/mod/features/fleet_actions.cc index cb08f3dc6..caa2272a6 100644 --- a/src/mod/features/fleet_actions.cc +++ b/src/mod/features/fleet_actions.cc @@ -486,7 +486,6 @@ bool FleetShortcuts() if (Shortcut::IsDown(Action::kToggleQueue)) { auto& queue_enabled = Config::Get().control.queue_enabled; queue_enabled = !queue_enabled; - ResetQueueRecoveryTracking(); return true; } return false; diff --git a/src/mod/features/fleet_labels.cc b/src/mod/features/fleet_labels.cc index c111cdfa8..24b93d191 100644 --- a/src/mod/features/fleet_labels.cc +++ b/src/mod/features/fleet_labels.cc @@ -20,10 +20,6 @@ namespace class FleetLabels { public: - // Set once every hook is in: an untracked OnDisable would leave a freed widget in the map. - void MarkInstalled() - { installed_ = true; } - static bool Enabled() { const auto& config = Config::Get(); @@ -80,10 +76,9 @@ class FleetLabels } // The camera `zoom` is about to show `depth`. Labels only apply in a solar system, and a new camera starts over. + // Reported whether or not the hooks went in; without them nothing is tracked, so there is nothing to apply. void OnDepth(NavigationZoom* zoom, NodeDepth depth) { - if (!installed_) - return; if (depth == NodeDepth::SolarSystem) Follow(zoom); else if (camera_.zoom == zoom) @@ -93,7 +88,7 @@ class FleetLabels // After the camera's own update: picks up the zoom level and re-applies labels when they change. void OnZoomed(NavigationZoom* zoom) { - if (!installed_ || zoom->depth() != NodeDepth::SolarSystem) + if (zoom->depth() != NodeDepth::SolarSystem) return; Follow(zoom); const bool was_known = camera_.zoom_known; @@ -177,7 +172,6 @@ class FleetLabels bool non_player_expanded = false; }; - bool installed_ = false; std::unordered_map widgets_; Camera camera_; } fleet_labels; @@ -223,9 +217,7 @@ bool InstallFleetLabels() { // Installed whether or not zoom_label_* is set, so labels can be switched at runtime. Missing fields and // properties (_lod, Context, FleetType) read as null or zero, which the hooks treat as native labels. The zoom - // reports come from camera.cc's NavigationZoom.Update hook, so that method has to exist too. - if (!NavigationZoom::Class::Method("Update")) - return false; + // reports come from camera.cc's Zoom hooks; without them threshold labels stay native and the others still apply. il2cpp::HookSet hooks("FleetLabels"); IL2CPP_STAGE(hooks, NavigationLOD::Class::Method("UpdateLOD"), NavigationLOD_UpdateLOD_Hook); IL2CPP_STAGE(hooks, NavigationFleetWidget::Class::Method("OnEnable"), NavigationFleetWidget_OnEnable_Hook); @@ -234,11 +226,7 @@ bool InstallFleetLabels() NavigationFleetWidget_OnDidBindContext_Hook); IL2CPP_STAGE(hooks, NavigationFleetWidget::Class::Method("OnAboutToReleaseContext"), NavigationFleetWidget_OnAboutToReleaseContext_Hook); - // camera.cc's zoom hooks report here whether or not this installed; they only count once it has. - if (!hooks.Commit()) - return false; - fleet_labels.MarkInstalled(); - return true; + return hooks.Commit(); } } // namespace diff --git a/src/mod/features/haven.cc b/src/mod/features/haven.cc index fa7670b41..23b18add6 100644 --- a/src/mod/features/haven.cc +++ b/src/mod/features/haven.cc @@ -16,97 +16,51 @@ #include #include #include -#include #include #include -#include #include -#include namespace { // --- Haven zoom --- // From further out the roads sink into the ground mesh. A depth bias on the road materials keeps them drawn on top. -// Finding the materials (GetComponentsInChildren and every material's name) is the costly part, so it runs every 5 s -// while the bias is checked every second. The handles are weak, so a cached material never keeps an unloaded scene or -// asset alive. -class HavenRoads +// Walking the visuals for them (GetComponentsInChildren and every material's name) is costly, so it runs every 5 s, +// which also picks up the materials of a base that was rebuilt. +bool IsHavenRoad(unity::Material* material) { -public: - void Apply() - { - if (!apply_.Due()) - return; - if (auto* owner = owner_.Get(); !unity::Alive(owner) || !owner->activeInHierarchy()) { - owner_.Reset(); - materials_.clear(); - } - // A replacement can take the visuals' path while the old object is still alive and active, so the owner is - // looked up again at every search, not only once it is gone. - if (search_.Due()) - Search(); - for (const auto& handle : materials_) - if (auto* material = handle.Get(); unity::Alive(material)) - Bias(material); - } - -private: - void Search() - { - materials_.clear(); - auto* root = unity::GameObject::Find("StarbaseRoot/StarbaseManager/StarbasePlanetaryVisualsHolder"); - owner_ = il2cpp::GcHandle::Weak(root); - std::unordered_set seen; - for (auto* renderer : il2cpp::Items(root ? root->GetComponentsInChildren(true) : nullptr)) - for (auto* material : il2cpp::Items(renderer ? renderer->sharedMaterials() : nullptr)) - if (material && IsRoad(material) && seen.insert(material).second) - materials_.push_back(il2cpp::GcHandle::Weak(material)); - } - - static bool IsRoad(unity::Material* material) - { - static constexpr std::string_view kRoadMaterials[] = { - "mat_PB_3101101_asphalt_", "mat_PB_3101201_asphalt_", "mat_PB_3101301_asphalt_", "mat_PB_3101401_asphalt_", - "mat_PB_3101501_asphalt_", "mat_PB_3101601_asphalt_", "mat_PB_3101701_asphalt_"}; - const auto name = material->Name(); - if (!std::ranges::any_of(kRoadMaterials, [&](std::string_view prefix) { return name.starts_with(prefix); })) - return false; - auto* shader = material->shader(); - return shader && shader->Name() == "Custom/LitMSAE"; - } - - static void Bias(unity::Material* material) - { - const std::array properties{il2cpp::NewString("_OffsetFactor"), il2cpp::NewString("_OffsetUnits")}; - if (!material->HasProperty(properties[0]) || !material->HasProperty(properties[1])) - return; - const std::array before{material->GetFloat(properties[0]), material->GetFloat(properties[1])}; - if (!std::isfinite(before[0]) || !std::isfinite(before[1])) - return; - // Negative offsets pull the roads toward the camera. Keep an already stronger native bias. - for (size_t i = 0; i < properties.size(); ++i) - if (before[i] > -1.0f) - material->SetFloat(properties[i], -1.0f); - } - - Throttle apply_{std::chrono::seconds(1)}, search_{std::chrono::seconds(5)}; - il2cpp::GcHandle owner_; - std::vector materials_; -}; + static constexpr std::string_view kRoadMaterials[] = { + "mat_PB_3101101_asphalt_", "mat_PB_3101201_asphalt_", "mat_PB_3101301_asphalt_", "mat_PB_3101401_asphalt_", + "mat_PB_3101501_asphalt_", "mat_PB_3101601_asphalt_", "mat_PB_3101701_asphalt_"}; + const auto name = material->Name(); + if (!std::ranges::any_of(kRoadMaterials, [&](std::string_view prefix) { return name.starts_with(prefix); })) + return false; + // The shader defines both offset properties. + auto* shader = material->shader(); + return shader && shader->Name() == "Custom/LitMSAE"; +} void ApplyHavenRoadDepthBias() { - // Leaked: its handles must not be freed by static destructors after the runtime is gone. - static auto* roads = new HavenRoads; - roads->Apply(); + static Throttle throttle(std::chrono::seconds(5)); + if (!throttle.Due()) + return; + auto* root = unity::GameObject::Find("StarbaseRoot/StarbaseManager/StarbasePlanetaryVisualsHolder"); + const std::array properties{il2cpp::NewString("_OffsetFactor"), il2cpp::NewString("_OffsetUnits")}; + // Negative offsets pull the roads toward the camera. Keep an already stronger native bias. + for (auto* renderer : il2cpp::Items(root ? root->GetComponentsInChildren(true) : nullptr)) + for (auto* material : il2cpp::Items(renderer ? renderer->sharedMaterials() : nullptr)) + if (material && IsHavenRoad(material)) + for (auto* property : properties) + if (material->GetFloat(property) > -1.0f) + material->SetFloat(property, -1.0f); } // Haven's zoom blends between two planetary orbits, each pinned to one radius (min == max) and sitting on it. Reads // that radius, or nothing for any other kind of endpoint. std::optional HavenOrbitRadius(FrameProvider* provider) { - if (!provider || !provider->Is() || !provider->Is()) + if (!provider || !provider->Is()) return std::nullopt; auto* orbit = provider->As(); auto* constraint = orbit->constraint(); @@ -115,23 +69,18 @@ std::optional HavenOrbitRadius(FrameProvider* provider) return std::nullopt; const auto minimum = data->min(), maximum = data->max(); const auto radius = orbit->radius(); - if (std::isfinite(radius) && std::isfinite(maximum) && maximum > 0.0f && minimum == maximum - && std::abs(radius - maximum) < 0.01f) + if (maximum > 0.0f && minimum == maximum && std::abs(radius - maximum) < 0.01f) return radius; return std::nullopt; } -// Every value is finite: NaN and infinity both fail. -bool HavenFinite(std::floating_point auto... values) -{ return (std::isfinite(values) && ...); } - void BlendFrameProvider_UpdateCameraFrame_Hook(auto original, BlendFrameProvider* _this, unity::Camera* camera) { // Native input, constraints and endpoint updates run first. Only this blend's newly generated output is extended; // endpoint assets and normalized LOD stay native. original(_this, camera); const auto max_zoom = Config::Get().graphics.haven_zoom; - if (!std::isfinite(max_zoom) || max_zoom <= 0.0f) + if (max_zoom <= 0.0f) return; auto* source = _this->sourceFrameProvider(); @@ -155,34 +104,28 @@ void BlendFrameProvider_UpdateCameraFrame_Hook(auto original, BlendFrameProvider CameraFrame* result = _this->blendedCameraFrame(); auto* curve = _this->blendCurve(); const auto minimum = _this->softLimitMin(), maximum = _this->softLimitMax(), ratio = _this->blendRatio(); - if (!source_frame || !target_frame || !result || !curve || source_frame == target_frame || result == source_frame - || result == target_frame || !HavenFinite(minimum, maximum, ratio) || minimum < 0.0f || maximum > 1.0f - || maximum <= minimum) + if (!source_frame || !target_frame || !result || !curve) return; auto position = result->position(); const auto ar = source_frame->rotation(), br = target_frame->rotation(); const auto af = source_frame->fov(), bf = target_frame->fov(); - const auto rotation_dot = ar.x * br.x + ar.y * br.y + ar.z * br.z + ar.w * br.w; - const auto gap = std::abs(source_radius - target_radius); - const auto rotation_length = ar.x * ar.x + ar.y * ar.y + ar.z * ar.z + ar.w * ar.w; - // Qualify the common-pivot, same-facing perspective orbit measured in Haven: unit rotations that agree up to sign + const auto rotation_dot = ar.x * br.x + ar.y * br.y + ar.z * br.z + ar.w * br.w; + const auto gap = std::abs(source_radius - target_radius); + // Qualify the common-pivot, same-facing perspective orbit measured in Haven: rotations that agree up to sign // (|dot| = 1) and one field of view. Only then is moving along the camera's back axis the same as a wider orbit. // Preserve native output when a client changes that geometry or camera mode. - if (!HavenFinite(rotation_length, rotation_dot, af, bf) || std::abs(rotation_length - 1.0f) > 0.0001f - || std::abs(std::abs(rotation_dot) - 1.0f) > 0.0001f || af <= 0.0f || std::abs(af - bf) > 0.001f + if (std::abs(std::abs(rotation_dot) - 1.0f) > 0.0001f || af <= 0.0f || std::abs(af - bf) > 0.001f || source_frame->orthographic() || target_frame->orthographic() || result->orthographic()) return; const auto at_ratio = curve->Evaluate(ratio); const auto at_minimum = curve->Evaluate(minimum); const auto at_maximum = curve->Evaluate(maximum); - if (!HavenFinite(at_ratio, at_minimum, at_maximum)) - return; // Weights run from 0 at the inner orbit to 1 at the outer one, whichever way the blend goes. outer_weight and // inner_weight are the curve at the soft limits, where the native zoom stops; progress is how far the current // ratio is between them. The extra distance grows with progress up to max_zoom - native_outer at the outer stop, - // and never takes the camera past max_zoom. + // and never takes the camera past max_zoom. Soft limits out of order give outer_weight <= inner_weight. const bool source_is_far = source_radius > target_radius; const auto outer_weight = source_is_far ? 1.0f - std::clamp(at_minimum, 0.0f, 1.0f) : std::clamp(at_maximum, 0.0f, 1.0f); @@ -196,8 +139,6 @@ void BlendFrameProvider_UpdateCameraFrame_Hook(auto original, BlendFrameProvider const auto native_distance = inner_radius + gap * weight; const auto progress = std::clamp((weight - inner_weight) / (outer_weight - inner_weight), 0.0, 1.0); const auto extra = std::min(max_zoom - native_distance, (max_zoom - native_outer) * progress); - if (!std::isfinite(extra)) - return; ApplyHavenRoadDepthBias(); if (extra <= 0.0) return; @@ -208,14 +149,8 @@ void BlendFrameProvider_UpdateCameraFrame_Hook(auto original, BlendFrameProvider position.x += static_cast(-2.0 * (ar.x * ar.z + ar.w * ar.y) * extra); position.y += static_cast(-2.0 * (ar.y * ar.z - ar.w * ar.x) * extra); position.z += static_cast((2.0 * (ar.x * ar.x + ar.y * ar.y) - 1.0) * extra); - auto clip = result->farPlane(); - if (!std::isfinite(clip) || clip <= 0.0f) - return; - clip += static_cast(extra); - if (!HavenFinite(position.x, position.y, position.z, clip) || clip <= 0.0f) - return; result->SetPosition(position); - result->SetFarPlane(clip); + result->SetFarPlane(result->farPlane() + static_cast(extra)); static bool reported = false; if (!std::exchange(reported, true)) spdlog::info("[HavenZoom] expanded native outer distance {} to maximum {}", native_outer, max_zoom); diff --git a/src/mod/features/instant_warp.cc b/src/mod/features/instant_warp.cc index f0f70556c..438f67f13 100644 --- a/src/mod/features/instant_warp.cc +++ b/src/mod/features/instant_warp.cc @@ -108,8 +108,6 @@ struct OwnedLabel { if (label->textOverride()) return; handle = il2cpp::GcHandle::Weak(label); - if (!handle) - return; } const auto current = CurrentLabelText(label); if (!text.empty() && label->textOverride() && current != text) { diff --git a/src/mod/features/keybind_badges.cc b/src/mod/features/keybind_badges.cc index bd7b683f3..d96c78259 100644 --- a/src/mod/features/keybind_badges.cc +++ b/src/mod/features/keybind_badges.cc @@ -12,7 +12,6 @@ #include #include -#include #include #include @@ -69,23 +68,11 @@ constexpr auto kNativeActions = std::to_array({ {"challenges", Action::kShowQTrials}, }); -constexpr const NativeAction* FindNativeAction(std::string_view name) -{ - const auto action = std::ranges::find(kNativeActions, name, &NativeAction::name); - return action == kNativeActions.end() ? nullptr : &*action; -} - -static_assert(std::ranges::all_of(kNativeActions, [](const NativeAction& action) { - return std::ranges::count(kNativeActions, action.name, &NativeAction::name) == 1; -})); -static_assert(FindNativeAction("side_chat")->fallback == Action::kShowChatSide2); -static_assert(FindNativeAction("unknown") == nullptr); - // The badge text for a native action: its mod binding, or "-" when it has none. std::string BadgeText(std::string_view action_name) { - const auto* action = FindNativeAction(action_name); - if (!action) + const auto action = std::ranges::find(kNativeActions, action_name, &NativeAction::name); + if (action == kNativeActions.end()) return "-"; auto hint_action = action->primary; if (hint_action >= Action::kSelectShip1 && hint_action <= Action::kSelectShip8) { @@ -142,13 +129,9 @@ bool ToggleKeybindBadges() { if (!badges_installed) return false; - auto* manager = ShortcutsManager::Instance(); - if (!manager) { - spdlog::warn("[Hotkeys] the game's shortcut manager is not up; no badges to toggle"); - return true; - } // The same gate the game's own show_keybindings action checks. - if (ShortcutsManager::CanUseShortcuts()) + auto* manager = ShortcutsManager::Instance(); + if (manager && ShortcutsManager::CanUseShortcuts()) manager->SetShowKeybindings(!manager->ShowKeybindings()); return true; } @@ -162,10 +145,6 @@ void HideKeybindBadges() void InstallKeybindBadges() { - // Settings are parsed before installation; NONE, empty and invalid bindings opt out. - if (!Shortcut::HasBinding(Action::kToggleShortcutHints)) - return; - // Only the detour targets gate the feature. Members the badge path reads resolve on first use; a missing one is // reported once and reads as null, which leaves the game's own badge text. il2cpp::HookSet hooks("KeybindBadges"); diff --git a/src/mod/features/notification_audio.cc b/src/mod/features/notification_audio.cc index cdb7aa480..7bc805dbc 100644 --- a/src/mod/features/notification_audio.cc +++ b/src/mod/features/notification_audio.cc @@ -7,7 +7,6 @@ #include #include #include -#include #include #include #include @@ -111,13 +110,13 @@ std::vector BuildWav(std::span pattern) std::span NotificationSoundBytes(NotificationSound sound) { - const size_t index = std::to_underlying(sound); - if (index == 0 || index >= std::size(kPatterns)) + const auto index = std::to_underlying(sound); + if (index == 0) return {}; - static std::array ready; + // Only the cues in use are generated. The toast hooks are the only callers, all on the game thread. static std::array, std::size(kPatterns)> buffers; - // Only the cues in use are generated. - std::call_once(ready[index], [index] { buffers[index] = BuildWav(kPatterns[index]); }); + if (buffers[index].empty()) + buffers[index] = BuildWav(kPatterns[index]); return buffers[index]; } diff --git a/src/mod/features/notification_audio_mac.mm b/src/mod/features/notification_audio_mac.mm index 271df4264..253cabb09 100644 --- a/src/mod/features/notification_audio_mac.mm +++ b/src/mod/features/notification_audio_mac.mm @@ -16,9 +16,7 @@ bool PlayNotificationBuffer(const uint8_t* data, size_t size) return false; [current_sound stop]; -#if !__has_feature(objc_arc) [current_sound release]; -#endif current_sound = sound; return [current_sound play]; } diff --git a/src/mod/features/toast_text.h b/src/mod/features/toast_text.h index 93824289b..633d9a171 100644 --- a/src/mod/features/toast_text.h +++ b/src/mod/features/toast_text.h @@ -73,7 +73,7 @@ inline std::string TitleCase(std::string text) } // A readable name from a hull's internal key, for hulls the game cannot localize: -// "Hull_L30_Destroyer_Klingon_LIVE" -> "Lv.30 Destroyer Klingon". +// "Hull_L30_Destroyer_Klingon_LIVE" -> "Lv.30 Destroyer Klingon". Casing is left to the caller. inline std::string HullKeyName(std::string_view key) { if (key.size() > 5 && key.ends_with("_LIVE")) @@ -85,6 +85,6 @@ inline std::string HullKeyName(std::string_view key) std::ranges::replace(name, '_', ' '); if (name.size() >= 2 && name[0] == 'L' && name[1] >= '0' && name[1] <= '9') name = "Lv." + name.substr(1); - return TitleCase(std::move(name)); + return name; } } // namespace toast_text diff --git a/src/mod/features/toasts.cc b/src/mod/features/toasts.cc index 765a3939e..51f2394b7 100644 --- a/src/mod/features/toasts.cc +++ b/src/mod/features/toasts.cc @@ -123,23 +123,10 @@ constexpr BattleType kPvpBattles[] = {BattleType::Fleet, BattleType::Base, BattleType::PvpChainShot}; // --- Notification text --- -// -// Toast data comes from a dozen game types whose layouts drift between builds. On Windows, an access violation -// while reading one is contained and logged instead of taking the game down, so every read below is Guarded. - -using il2cpp::Guarded; - -// Localizes `identifier` with one parameter. -std::string Localize(const char* identifier, const char* category, Il2CppObject* parameter) -{ - std::string result; - Guarded(identifier, [&] { result = LocaleUtilities::Localize(identifier, category, parameter); }); - return result; -} // NPC and marauder profiles have no name of their own; the game names them by their localization id. std::string EnemyName(int64_t loca_id) -{ return loca_id ? Localize("marauder_name_only_{0}", "navigation", il2cpp::Box(loca_id)) : std::string{}; } +{ return loca_id ? LocaleUtilities::Localize("marauder_name_only_{0}", "navigation", il2cpp::Box(loca_id)) : ""; } // A hull's display name: localized by numeric id, then by string id, then derived from its internal key. std::string HullName(BattleResultHeader* header, int64_t hull_id) @@ -153,7 +140,7 @@ std::string HullName(BattleResultHeader* header, int64_t hull_id) // An unknown id localizes to the identifier itself, which counts as no name. const auto localized = [](Il2CppObject* id, const std::string& id_text) { - auto name = Localize("ship_name_{0}", "ships", id); + auto name = LocaleUtilities::Localize("ship_name_{0}", "ships", id); return name == "ship_name_" + id_text ? std::string{} : name; }; if (auto* ids = hull->idRefs()) { @@ -172,28 +159,20 @@ std::string HullName(BattleResultHeader* header, int64_t hull_id) // "Player (Ship) vs Enemy (Ship)". Outside PvP a named enemy is enough; its hull class is noise. std::string BattleBody(BattleResultHeader* header) { - std::string player, enemy, player_ship, enemy_ship; - bool pvp = false; - - Guarded("the player profile", [&] { - if (auto* profile = header->PlayerUserProfile()) - player = il2cpp::ToUtf8(profile->name()); - }); - Guarded("the enemy profile", [&] { - auto* profile = header->EnemyUserProfile(); - if (!profile) - return; + std::string player, enemy; + if (auto* profile = header->PlayerUserProfile()) + player = il2cpp::ToUtf8(profile->name()); + if (auto* profile = header->EnemyUserProfile()) { enemy = il2cpp::ToUtf8(profile->name()); if (enemy.empty()) { auto name = EnemyName(profile->locaId()); if (name != "Unknown") enemy = std::move(name); } - }); - Guarded("the battle type", [&] { pvp = std::ranges::contains(kPvpBattles, header->Type()); }); - Guarded("the player hull", - [&] { player_ship = toast_text::TitleCase(HullName(header, header->PlayerShipHullId())); }); - Guarded("the enemy hull", [&] { enemy_ship = toast_text::TitleCase(HullName(header, header->EnemyShipHullId())); }); + } + const bool pvp = std::ranges::contains(kPvpBattles, header->Type()); + const auto player_ship = toast_text::TitleCase(HullName(header, header->PlayerShipHullId())); + const auto enemy_ship = toast_text::TitleCase(HullName(header, header->EnemyShipHullId())); const auto side = [](const std::string& name, const std::string& ship) { return name.empty() ? ship : ship.empty() ? name : fmt::format("{} ({})", name, ship); @@ -208,28 +187,24 @@ std::string BattleBody(BattleResultHeader* header) // "[TAG] Owner started an Armada!\nLv.042 Target" std::string ArmadaCreatedBody(ArmadaCreatedData* data) { - std::string body; - Guarded("armada data", [&] { - auto* attack = data ? data->ArmadaAttack() : nullptr; - auto* alliance = data ? data->Alliance() : nullptr; - auto* owner = attack ? attack->Owner() : nullptr; - auto* target = attack ? attack->TargetUserProfile() : nullptr; - - const auto tag = alliance ? il2cpp::ToUtf8(alliance->tag()) : std::string{}; - auto owner_name = owner ? il2cpp::ToUtf8(owner->name()) : std::string{}; - auto target_name = target ? il2cpp::ToUtf8(target->name()) : std::string{}; - if (target_name.empty() && target) - target_name = EnemyName(target->locaId()); - if (owner_name.empty()) - owner_name = "An alliance member"; - if (target_name.empty() || target_name == "Unknown") - target_name = "Armada target"; - - const auto owner_label = tag.empty() ? owner_name : fmt::format("[{}] {}", tag, owner_name); - const auto level = attack ? attack->TargetLevel() : 0; - body = fmt::format("{} started an Armada!\nLv.{:03} {}", owner_label, level, target_name); - }); - return body; + auto* attack = data ? data->ArmadaAttack() : nullptr; + auto* alliance = data ? data->Alliance() : nullptr; + auto* owner = attack ? attack->Owner() : nullptr; + auto* target = attack ? attack->TargetUserProfile() : nullptr; + + const auto tag = alliance ? il2cpp::ToUtf8(alliance->tag()) : std::string{}; + auto owner_name = owner ? il2cpp::ToUtf8(owner->name()) : std::string{}; + auto target_name = target ? il2cpp::ToUtf8(target->name()) : std::string{}; + if (target_name.empty() && target) + target_name = EnemyName(target->locaId()); + if (owner_name.empty()) + owner_name = "An alliance member"; + if (target_name.empty() || target_name == "Unknown") + target_name = "Armada target"; + + const auto owner_label = tag.empty() ? owner_name : fmt::format("[{}] {}", tag, owner_name); + const auto level = attack ? attack->TargetLevel() : 0; + return fmt::format("{} started an Armada!\nLv.{:03} {}", owner_label, level, target_name); } // The string form of each element: strings as they are, everything else through its ToString override. @@ -237,10 +212,7 @@ std::vector ParameterValues(il2cpp::Array* parameter { std::vector values; for (auto* element : il2cpp::Items(parameters)) { - Il2CppString* text = nullptr; - if (element) - Guarded("a text parameter", [&] { text = il2cpp::ToString(static_cast(element)); }); - values.push_back(il2cpp::ToUtf8(text)); + values.push_back(element ? il2cpp::ToUtf8(il2cpp::ToString(static_cast(element))) : ""); } return values; } @@ -351,11 +323,8 @@ void Notify(Toast* toast) const auto& types = Config::Get().ui.notify_banner_types; if (!std::ranges::contains(types, std::to_underlying(state))) return; + // notify_banner_types only holds states from the config catalog, and kTitles names every one of them. const auto title = std::ranges::find(kTitles, state, &std::pair::first); - if (title == std::end(kTitles)) { - spdlog::debug("[Toasts] No title mapping for toast state {}, skipping", std::to_underlying(state)); - return; - } ShowWindowsNotification(title->second, NotificationBody(toast)); #else (void)toast; diff --git a/src/mod/game/app.h b/src/mod/game/app.h index 71c1d5139..fbc36495f 100644 --- a/src/mod/game/app.h +++ b/src/mod/game/app.h @@ -8,7 +8,6 @@ #include #include #include -#include enum class SectionID : int32_t { ArtifactHall_Inventory = -2058246328, @@ -218,25 +217,6 @@ struct Model : il2cpp::Object { { return Field<"_appConfig", AppConfig*>(); } }; -// Research progress. Its members resolve on the runtime class. -struct ResearchManager : il2cpp::Object { - // Whether a visible research needs the resource. Empty when that could not be determined. - std::optional IsResourceRequiredByVisibleResearches(int64_t resource_id) - { - // Digit.PrimeServer.Models.ResearchTreeType; 0 is the main tree. - enum class ResearchTreeType : int32_t {}; - return TryCall<"IsResourceRequiredByVisibleResearches", bool(int64_t, ResearchTreeType)>(resource_id, - ResearchTreeType{}); - } -}; - -struct TutorialManager : il2cpp::Object { - using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Tutorial", "TutorialManager">; - - bool IsSceneInputAllowed() - { return Call<"IsSceneInputAllowed", bool>(); } -}; - // Digit.Client.Core.Hub is a static class. struct Hub { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.Core", "Hub">; @@ -245,10 +225,6 @@ struct Hub { { return Class::Get<"SectionManager", SectionManager*>(); } static PrimeApp* App() { return Class::Get<"App", PrimeApp*>(); } - static TutorialManager* Tutorial() - { return Class::Get<"TutorialManager", TutorialManager*>(); } - static ResearchManager* Research() - { return Class::Get<"ResearchManager", ResearchManager*>(); } static SectionID CurrentSection() { diff --git a/src/mod/game/fleet.h b/src/mod/game/fleet.h index d2b7c949e..b3e93bf2e 100644 --- a/src/mod/game/fleet.h +++ b/src/mod/game/fleet.h @@ -60,13 +60,6 @@ struct NodeAddress : il2cpp::Object { { return Get<"Instance", int32_t>(); } }; -// IDeployedFleet: the server's model behind a deployed fleet. Its class varies (DeployedFleet, DeployedFleetJSON), -// so members resolve on the runtime class. -struct DeployedFleetModel : il2cpp::Object { - int64_t FleetId() - { return Get<"FleetId", int64_t>(); } -}; - struct FleetDeployedData : il2cpp::Object { using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "FleetDeployedData">; @@ -87,13 +80,8 @@ struct FleetDeployedData : il2cpp::Object { { return Get<"CurrentlyBattling", bool>(); } bool IsPlanningRecallCourse() { return Get<"IsPlanningRecallCourse", bool>(); } - // True for the player's own fleets. - bool localPlayerFleet() - { return Field<"_localPlayerFleet", bool>(); } NodeAddress* address() { return Field<"_address", NodeAddress*>(); } - DeployedFleetModel* deploymentFleet() - { return Field<"_deploymentFleet", DeployedFleetModel*>(); } }; struct BattleTargetData : il2cpp::Object { diff --git a/src/mod/game/inventory.h b/src/mod/game/inventory.h index fa070f921..135077ff6 100644 --- a/src/mod/game/inventory.h +++ b/src/mod/game/inventory.h @@ -8,7 +8,6 @@ #include #include -#include struct InterstitialViewController : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Interstitial", "InterstitialViewController">; @@ -55,80 +54,15 @@ struct Bundle : il2cpp::Object { using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimePlatform.Content", "Bundle">; }; -struct ShopListScrollerViewController; -struct InventoryTooltipTriggerWidget; - -// A shop offer. -struct BundleDataWidget : Widget { - using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Shop", "BundleDataWidget">; - - // ItemState flags; only the one the mod reads is named. - static constexpr int32_t kCooldownTimerOn = 16384; - - int32_t currentState() - { return Field<"_currentState", int32_t>(); } - inline InventoryTooltipTriggerWidget* invTooltip(); - // Set by OnDidBindContext after the card set up its tooltip. - inline ShopListScrollerViewController* scrollerController(); - - void AuxViewButtonPressedHandler() - { Call<"AuxViewButtonPressedHandler">(); } -}; - -struct DrawerContext : il2cpp::Object { - using Class = il2cpp::Class<"Assembly-CSharp", "Prime.SharedFeatures.Scripts.UI.Widgets", "DrawerContext">; - - bool Enabled() - { return Field<"Enabled", bool>(); } -}; - -struct DrawerWidget : Widget { - using Class = il2cpp::Class<"Assembly-CSharp", "Prime.SharedFeatures.Scripts.UI.Widgets", "DrawerWidget">; - - void OnOpenButtonClicked() - { Call<"OnOpenButtonClicked">(); } -}; - -// The gifts screen; its selection drawer holds bulk claim. -struct ShopListScrollerViewController : il2cpp::Object { - using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Shop", "ShopListScrollerViewController">; - - DrawerWidget* selectionDrawerWidget() - { return Field<"_selectionDrawerWidget", DrawerWidget*>(); } - // Declared as some scroller; the store only uses a SmartScrollerBase. - il2cpp::Object* smartScroller() - { return Field<"_smartScroller", il2cpp::Object*>(); } -}; - // Digit.Client.Serialization.OverridableBool: the serialized flag and the runtime override. struct OverridableBool { bool serialized, current, modified; }; -struct ActionLocaleSetting : il2cpp::Object { - using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Actions", "ActionLocaleSetting">; -}; - -struct ActionLocaleSettingsList : il2cpp::Object { - using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Actions", "ActionLocaleSettingsList">; -}; - -struct TooltipLoadAndShow : il2cpp::Object { - using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.Tooltip", "TooltipLoadAndShow">; -}; - -// TryEnabled and TrySetEnabled tell a failed call from the value, so the store can undo a half-made swap. struct TooltipTrigger : unity::Behaviour { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.Tooltip", "TooltipTrigger">; - TooltipLoadAndShow* loader() - { return Field<"_loader", TooltipLoadAndShow*>(); } - std::optional TryEnabled() - { return TryCall<"get_enabled", bool()>(); } - bool TrySetEnabled(bool value) - { return TryCall<"set_enabled", void(bool)>(value); } - - // Every serialized appearance field: what to show, where, and how it loads. + // Every appearance field, so a trigger added beside this one looks and loads the same. void CopyAppearance(TooltipTrigger* source) { SetField<"_showHeader">(source->Field<"_showHeader", OverridableBool>()); @@ -136,164 +70,128 @@ struct TooltipTrigger : unity::Behaviour { SetField<"_showDescription">(source->Field<"_showDescription", OverridableBool>()); SetField<"_description">(source->Field<"_description", LocaleTextContext*>()); SetField<"_overrideLocale">(source->Field<"_overrideLocale", bool>()); - // Digit.Client.Tooltip.TooltipAnchor; -1 is Undefined. - SetField<"_anchorOverride">(source->Field<"_anchorOverride", int32_t>()); + SetField<"_anchorOverride">(source->Field<"_anchorOverride", int32_t>()); // TooltipAnchor SetField<"_sizeOverride">(source->Field<"_sizeOverride", unity::Vector2>()); SetField<"_positionOverrideTarget">(source->Field<"_positionOverrideTarget", unity::Transform*>()); SetField<"_positionOverrideOffset">(source->Field<"_positionOverrideOffset", unity::Vector2>()); SetField<"_isInteractable">(source->Field<"_isInteractable", bool>()); SetField<"k__BackingField">( source->Field<"k__BackingField", Il2CppString*>()); - SetField<"_loader">(source->loader()); + SetField<"_loader">(source->Field<"_loader", il2cpp::Object*>()); } }; +// A tooltip trigger with action buttons, such as VIEW RESEARCH. struct TooltipActionTrigger : TooltipTrigger { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.Tooltip", "TooltipActionTrigger">; }; -// What a store tooltip shows. Its members resolve on the runtime class. -struct TooltipItem : il2cpp::Object { - // ResourceSpec.Id; 0 when there is none. - int64_t ResourceId() - { - auto* spec = Get<"ResourceSpec", il2cpp::Object*>(); - return spec ? spec->Get<"Id", int64_t>() : 0; - } -}; - -// The tooltip on an inventory or shop item. The Alliance Games store swaps a card's plain trigger for an action -// trigger that carries the same appearance plus the native research actions, and swaps it back on release. +// The tooltip on an inventory or shop item. Refresh writes the item into `_tooltipTrigger` and sets up the actions on +// `_tooltipActionTrigger`, labelled from its action locale assets, when it has one. struct InventoryTooltipTriggerWidget : unity::Component { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Inventories", "InventoryTooltipTriggerWidget">; - TooltipItem* Context() - { return Get<"Context", TooltipItem*>(); } - TooltipTrigger* tooltipTrigger() { return Field<"_tooltipTrigger", TooltipTrigger*>(); } - void SetTooltipTrigger(TooltipTrigger* value) - { SetField<"_tooltipTrigger">(value); } TooltipActionTrigger* tooltipActionTrigger() { return Field<"_tooltipActionTrigger", TooltipActionTrigger*>(); } - void SetTooltipActionTrigger(TooltipActionTrigger* value) - { SetField<"_tooltipActionTrigger">(value); } - ActionLocaleSetting* searchActionLocaleSetting() - { return Field<"_searchActionLocaleSetting", ActionLocaleSetting*>(); } - void SetSearchActionLocaleSetting(ActionLocaleSetting* value) - { SetField<"_searchActionLocaleSetting">(value); } - ActionLocaleSetting* actionLocaleSetting() - { return Field<"_actionLocaleSetting", ActionLocaleSetting*>(); } - void SetActionLocaleSetting(ActionLocaleSetting* value) - { SetField<"_actionLocaleSetting">(value); } - ActionLocaleSettingsList* settings() - { return Field<"_settings", ActionLocaleSettingsList*>(); } - void SetSettings(ActionLocaleSettingsList* value) - { SetField<"_settings">(value); } - - bool UpdateTooltipDescription() - { return TryCall<"UpdateTooltipDescription", void()>(); } - bool SetupActionTooltipTrigger() - { return TryCall<"SetupActionTooltipTrigger", void()>(); } - - // The plain trigger on this GameObject; null when it is missing or is already an action trigger. - TooltipTrigger* PlainTrigger() - { - auto* object = gameObject(); - auto* trigger = object ? object->GetComponent() : nullptr; - return trigger && trigger->klass == TooltipTrigger::Class::Raw() ? trigger : nullptr; - } - // The action trigger on this GameObject. Empty when the lookup failed, which is not the same as there being none. - std::optional ActionTrigger() - { - auto* object = gameObject(); - auto* type = TooltipActionTrigger::Class::TypeObject(); - if (!object || !type) - return std::nullopt; - return object->TryCall<"GetComponent", TooltipActionTrigger*(Il2CppReflectionType*)>(type); - } - TooltipActionTrigger* AddActionTrigger() + il2cpp::Object* searchActionLocaleSetting() + { return Field<"_searchActionLocaleSetting", il2cpp::Object*>(); } + // Shows the tooltip through `action`, with the action locale assets of `source`. + void UseActionTrigger(TooltipActionTrigger* action, InventoryTooltipTriggerWidget* source) { - auto* object = gameObject(); - return object ? object->AddComponent() : nullptr; + SetField<"_tooltipTrigger">(action); + SetField<"_tooltipActionTrigger">(action); + SetField<"_actionLocaleSetting">(source->Field<"_actionLocaleSetting", il2cpp::Object*>()); + SetField<"_searchActionLocaleSetting">(source->searchActionLocaleSetting()); + SetField<"_settings">(source->Field<"_settings", il2cpp::Object*>()); } + // Private, and only its own methods can reach it, so the widget's bind path sets up the actions; which of its + // methods calls it is not visible in the metadata. + void SetupActionTooltipTrigger() + { Call<"SetupActionTooltipTrigger">(); } + void Refresh() + { Call<"Refresh">(); } +}; + +// A shop offer. +struct BundleDataWidget : Widget { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Shop", "BundleDataWidget">; + + // ItemState flags; only the one the mod reads is named. + static constexpr int32_t kCooldownTimerOn = 16384; + + int32_t currentState() + { return Field<"_currentState", int32_t>(); } + InventoryTooltipTriggerWidget* invTooltip() + { return Field<"_invTooltip", InventoryTooltipTriggerWidget*>(); } + // The list showing the card. OnDidBindContext sets it after the card's tooltip is bound. + il2cpp::Object* scrollerController() + { return Field<"_scrollerController", il2cpp::Object*>(); } + + void AuxViewButtonPressedHandler() + { Call<"AuxViewButtonPressedHandler">(); } }; -// The smart scroller's card pool and layout. +struct DrawerContext : il2cpp::Object { + using Class = il2cpp::Class<"Assembly-CSharp", "Prime.SharedFeatures.Scripts.UI.Widgets", "DrawerContext">; + + bool Enabled() + { return Field<"Enabled", bool>(); } +}; + +struct DrawerWidget : Widget { + using Class = il2cpp::Class<"Assembly-CSharp", "Prime.SharedFeatures.Scripts.UI.Widgets", "DrawerWidget">; + + void OnOpenButtonClicked() + { Call<"OnOpenButtonClicked">(); } +}; + +// A recycling list. Scrolling calls OnValueChangedCallback, which binds the cards in view. struct SmartScrollerBase : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.UI", "SmartScrollerBase">; - // The spawned card transforms. - il2cpp::List* spawnedItems() - { return Field<"_spawnedItems", il2cpp::List*>(); } - // Cards are still spawning while any of these is set. - bool regeneratingContent() - { return Field<"_regeneratingContent", bool>(); } - il2cpp::Object* spawnerCoroutine() - { return Field<"_spawnerCoroutine", il2cpp::Object*>(); } - il2cpp::Object* dataHeldWhilstInitializing() - { return Field<"_dataHeldWhilstInitializing", il2cpp::Object*>(); } - - std::optional NormalizedPosition() - { return TryCall<"get_NormalizedPosition", unity::Vector2()>(); } - // Lays the visible cards out again for `position`, as a scroll to it would. - bool OnValueChangedCallback(unity::Vector2 position) - { return TryCall<"OnValueChangedCallback", void(unity::Vector2)>(position); } - std::optional VisibleItemsCount() - { return TryCall<"get_VisibleItemsCount", int32_t()>(); } - std::optional ScrollPosition() - { return TryCall<"get_ScrollPosition", float()>(); } - - // How many cards are spawned; empty when the pool is missing or its count could not be read. - std::optional SpawnedCount() + // Regenerated content can spawn its cards over several frames. + bool Spawning() { - auto* items = spawnedItems(); - return items ? items->TryCall<"get_Count", int32_t()>() : std::nullopt; + return Field<"_regeneratingContent", bool>() || Field<"_spawnerCoroutine", il2cpp::Object*>() + || Field<"_dataHeldWhilstInitializing", il2cpp::Object*>(); } + // Binds the cards in view again, as a scroll to where the list already is would. + void Rebind() + { Call<"OnValueChangedCallback">(Get<"NormalizedPosition", unity::Vector2>()); } +}; - // The tooltip widget on a spawned card, active or not. - static InventoryTooltipTriggerWidget* CardTooltip(unity::Component* card) - { - auto* type = InventoryTooltipTriggerWidget::Class::TypeObject(); - return type ? card->Call<"GetComponentInChildren", InventoryTooltipTriggerWidget*(Il2CppReflectionType*, bool)>( - type, true) - : nullptr; - } +// The gifts screen; its selection drawer holds bulk claim. +struct ShopListScrollerViewController : il2cpp::Object { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Shop", "ShopListScrollerViewController">; + + DrawerWidget* selectionDrawerWidget() + { return Field<"_selectionDrawerWidget", DrawerWidget*>(); } + SmartScrollerBase* smartScroller() + { return Field<"_smartScroller", SmartScrollerBase*>(); } }; -// The store's context. Its members resolve on the runtime class. -struct AllianceGamesStoreContext : il2cpp::Object { - bool IsShopViewActive() - { return Get<"IsShopViewActive", bool>(); } - std::optional IsFetchingBundlesInProgress() - { return TryCall<"get_IsFetchingBundlesInProgress", bool()>(); } - int32_t BundlesCount() - { return Get<"BundlesCount", int32_t>(); } - // Rebuilds the store's currency list from its bundles. - bool UpdateStoreCurrency() - { return TryCall<"UpdateStoreCurrency", void()>(); } - // How many soft currencies the store shows. - std::optional CurrencyCount() - { - auto* currencies = Get<"StoreCurrency", il2cpp::Object*>(); - auto* soft = currencies ? currencies->Get<"SoftResources", il2cpp::List*>() : nullptr; - return soft ? soft->TryCall<"get_Count", int32_t()>() : std::nullopt; - } +// The Alliance Games screen's context. It wraps the shop's context and keeps the store's currency list. +struct AllianceGamesSectionContext : il2cpp::Object { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Alliances.Games", "AllianceGamesSectionContext">; + + bool HasBundles() + { return !Get<"IsFetchingBundlesInProgress", bool>() && Get<"BundlesCount", int32_t>() > 0; } + // Rebuilds the currency list from the store's bundles. + void UpdateStoreCurrency() + { Call<"UpdateStoreCurrency">(); } }; +// Its OnContextUpdatedEventCallback override is empty, so it does none of the regular store's context handling. struct AllianceGamesStoreScrollerViewController : ShopListScrollerViewController { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Alliances.Games", "AllianceGamesStoreScrollerViewController">; - AllianceGamesStoreContext* AllianceGamesContext() - { return Get<"AllianceGamesContext", AllianceGamesStoreContext*>(); } + AllianceGamesSectionContext* AllianceGamesContext() + { return Get<"AllianceGamesContext", AllianceGamesSectionContext*>(); } }; -InventoryTooltipTriggerWidget* BundleDataWidget::invTooltip() -{ return Field<"_invTooltip", InventoryTooltipTriggerWidget*>(); } -ShopListScrollerViewController* BundleDataWidget::scrollerController() -{ return Field<"_scrollerController", ShopListScrollerViewController*>(); } - // Shows the shop's reveal sequences and offer popups. Only hooked. using ShopSceneManager = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Shop", "ShopSceneManager">; diff --git a/src/mod/game/navigation.h b/src/mod/game/navigation.h index 1d3556669..a04dca8d7 100644 --- a/src/mod/game/navigation.h +++ b/src/mod/game/navigation.h @@ -191,19 +191,9 @@ struct NavigationInteractionUIViewController : il2cpp::Object { } }; -struct PopulatedSystemData : il2cpp::Object { - using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation", "PopulatedSystemData">; - - Il2CppObject* VariationCollection() - { return Field<"k__BackingField", Il2CppObject*>(); } -}; - struct PlanetViewUtils : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation", "PlanetViewUtils">; - PopulatedSystemData* popData() - { return Field<"_popData", PopulatedSystemData*>(); } - // The planet's 2D sprite, a Component. Calling this also runs any detour on get_FlatRenderable. unity::Component* FlatRenderable() { return static_cast(Get<"FlatRenderable", Il2CppObject*>()); } diff --git a/src/mod/game/ui.h b/src/mod/game/ui.h index f8a55815c..d609f5636 100644 --- a/src/mod/game/ui.h +++ b/src/mod/game/ui.h @@ -481,13 +481,10 @@ struct ArtifactHallDetailsViewController : unity::Behaviour { } }; -// The cargo bar's "X of Y" label. Its fields are declared on LegacyProgressBar and Widget`1; lookups walk the -// parents. +// The cargo bar. Its fields are declared on LegacyProgressBar and Widget`1; lookups walk the parents. struct LegacyComparableProgressBar : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Stats", "LegacyComparableProgressBar">; - TextLocalizer* currentValue_X_of_Y() - { return Field<"_currentValue_X_of_Y", TextLocalizer*>(); } // IProgressData il2cpp::Object* context() { return Field<"m_context", il2cpp::Object*>(); } diff --git a/src/mod/shortcuts.cc b/src/mod/shortcuts.cc index 07aa4986f..63bfd2f64 100644 --- a/src/mod/shortcuts.cc +++ b/src/mod/shortcuts.cc @@ -175,12 +175,6 @@ const std::vector& ChordsOf(Action action) // release, so letting go of CTRL before PGUP does not turn a held CTRL-PGUP into PGUP. At most one entry per key. std::vector used_keys; -bool IsUsed(unity::KeyCode key) -{ - std::erase_if(used_keys, [](unity::KeyCode used) { return !unity::Key::Pressed(used); }); - return std::ranges::contains(used_keys, key); -} - bool ModifiersHeld(const KeyChord& chord) { return std::ranges::all_of( @@ -189,11 +183,14 @@ bool ModifiersHeld(const KeyChord& chord) template bool Matches(Action action) { + // Pruned on every match, not only when a plain chord's key is down: the dispatch matches every frame, so a key + // released and pressed again between two of its checks is not mistaken for one still held. + std::erase_if(used_keys, [](unity::KeyCode used) { return !unity::Key::Pressed(used); }); for (const auto& chord : ChordsOf(action)) { if (chord.key == unity::KeyCode::None || !KeyState(chord.key)) continue; if (chord.modifiers.empty()) { - if (unity::Key::IsModified() || IsUsed(chord.key)) + if (unity::Key::IsModified() || std::ranges::contains(used_keys, chord.key)) continue; } else { if (!ModifiersHeld(chord)) @@ -242,8 +239,8 @@ bool BindChords(std::vector& chords, std::string_view key, std::string return bound; } -// Binds one [shortcuts] setting into `chords`, and records the binding and where it came from (default, config, -// alias: or invalid) for the vars file. +// Binds one [shortcuts] setting into `chords`, and records the binding and where it came from (default, config or +// alias:) for the vars file. void BindSetting(const toml::table* shortcuts, toml::table& effective, toml::table& sources, const Setting& setting, std::vector& chords) { @@ -279,15 +276,10 @@ void BindSetting(const toml::table* shortcuts, toml::table& effective, toml::tab if (value.empty() || value == "NONE") return record("NONE", label); + // Only a configured value can fail here: tests/shortcut_defaults.toml pins every default to a valid chord. if (!BindChords(chords, key, value)) { - if (configured) - spdlog::warn("invalid config value shortcuts.{}: '{}'; using the default {}", key, *configured, setting.fallback); - if (!configured || !BindChords(chords, setting.key, setting.fallback)) { - // A broken entry in shortcuts.def, not a user mistake. - spdlog::error("default shortcut shortcuts.{} = '{}' has no valid chord; the action is unbound", setting.key, - setting.fallback); - return record("NONE", "invalid"); - } + spdlog::warn("invalid config value shortcuts.{}: '{}'; using the default {}", key, value, setting.fallback); + BindChords(chords, setting.key, setting.fallback); label = "default"; } record(DescribeChords(chords), label); @@ -359,14 +351,6 @@ void Set(Bindings next) noexcept used_keys.clear(); } -bool Owns(unity::KeyCode key) -{ return IsUsed(key); } - -bool HasBinding(Action action) -{ - return std::ranges::any_of(ChordsOf(action), [](const auto& chord) { return chord.key != unity::KeyCode::None; }); -} - bool IsPressed(Action action) { return Matches(action); } diff --git a/src/mod/shortcuts.h b/src/mod/shortcuts.h index 44a61cc24..6d9c0f19d 100644 --- a/src/mod/shortcuts.h +++ b/src/mod/shortcuts.h @@ -55,14 +55,11 @@ Bindings Parse(const toml::table& config, toml::table& parsed, bool extended, bo // Activates a complete set of bindings and releases keys owned by the previous set. Game thread only. void Set(Bindings bindings) noexcept; -bool HasBinding(Action action); // Any binding of `action` is held with its modifiers. A binding without modifiers needs none held; one with // modifiers still fires when extra modifiers are held (CTRL-F1 fires under CTRL+SHIFT). bool IsPressed(Action action); // Any binding of `action` went down this frame with its modifiers held. bool IsDown(Action action); -// A binding with modifiers matched on `key`, which is still held. Plain bindings on it wait for the release. -bool Owns(unity::KeyCode key); // "CTRL-G | F8" std::string Describe(Action action); diff --git a/src/mod/sync/fleets.cc b/src/mod/sync/fleets.cc index 65382b9e3..d65eb493e 100644 --- a/src/mod/sync/fleets.cc +++ b/src/mod/sync/fleets.cc @@ -16,6 +16,7 @@ #include #include #include +#include #include namespace data_sync @@ -244,197 +245,74 @@ namespace // --- Fleet watch --- // - // The client fills in the fleet slots over several frames after login, so a change seen meanwhile is not a - // transition. A baseline waits until the slots have been quiet for kFleetStableFor, or kFleetStableAtMost after the - // first fleet appeared, then sends a snapshot. Only after that is each state change of a known fleet sent. A slot - // that changes hands, or a fleet service that goes away, starts a new baseline. - - // While waiting for a baseline every slot is read each second for the first 30 s, then every 5 s. Without a fleet - // service for two minutes (the login screen) only a check every 5 s remains. - constexpr auto kFleetScanInterval = 1s; - constexpr auto kFleetSlowScanInterval = 5s; - constexpr auto kFleetFastScanPeriod = 30s; - constexpr auto kFleetServiceWait = 120s; - constexpr auto kFleetDiscoveryEvery = 5s; - constexpr auto kFleetStableFor = 5s; - constexpr auto kFleetStableAtMost = 30s; - // After the baseline one slot is read per probe, so every slot every 2.5 s. - constexpr auto kFleetProbeEvery = 250ms; + // Every slot is read once a second. A state change of the fleet a slot already held is sent as a record. A slot + // that empties, fills or changes hands is not a transition: it schedules a snapshot once the slots have been quiet + // for kFleetSettle, which covers the client filling the slots over several frames after login. State changes seen + // while a snapshot is pending go out with it. + + constexpr auto kFleetPollEvery = 1s; + constexpr auto kFleetSettle = 5s; class FleetWatcher { public: - // Forgets every slot and starts a new baseline. - void Reset(Clock::time_point now) - { - *this = {}; - seed_started_ = now; - } - void Tick(Clock::time_point now) { - if (seeding_) - Seed(now); - else if (!serviced_) - Discover(now); - else - Probe(now); - } - - private: - struct Slot { - int64_t fleet_id = 0; - FleetState state = FleetState::Unknown; - bool occupied = false; - }; - - void Restart(Clock::time_point now, int slot, int64_t fleet_id) - { - logging::Debug("process", "fleets", "fleet slots changed (slot {}, fleet {}); waiting for a new baseline", slot, - fleet_id); - Reset(now); - } - - // A slot that emptied, filled, or holds another fleet than last seen. - bool Moved(int slot, FleetPlayerData* fleet) const - { - const auto& known = slots_[slot]; - return known.occupied != (fleet != nullptr) || (fleet && known.fleet_id != fleet->Id()); - } - - // Records a fleet's state. While seeding a change only delays the baseline; afterwards it is sent. - void Observe(Clock::time_point now, int slot, FleetPlayerData* fleet) - { - auto& known = slots_[slot]; - const auto state = fleet->CurrentState(); - const bool same_fleet = known.occupied && known.fleet_id == fleet->Id(); - if (same_fleet && known.state == state) + if (now < next_poll_) return; - const auto before = known.state; - known = {fleet->Id(), state, true}; - if (!seeding_) { - Append(json::array({Record(slot, fleet, state, before)}), false); + next_poll_ = now + kFleetPollEvery; + auto* manager = FleetsManager::Instance(); + // Logged out: forget the slots, so the next login fills them in afresh. + if (!manager || !manager->HasFleetService()) { + slots_ = {}; + snapshot_at_.reset(); return; } - if (!observed_) { - observed_ = true; - first_seen_ = now; - } - last_change_ = now; - finalizing_ = false; - } - - // Reads every slot while seeding. False without a fleet service. - bool Scan(Clock::time_point now) - { - auto* manager = FleetsManager::Instance(); - if (!manager || !manager->HasFleetService()) - return false; - std::array fleets{}; - bool moved = false; for (int slot = 0; slot < kFleetSlotCount; ++slot) { - fleets[slot] = manager->GetFleetPlayerData(slot); - moved |= Moved(slot, fleets[slot]); - } - // A fleet arriving or leaving delays the baseline like a state change. - if (moved && observed_) { - last_change_ = now; - finalizing_ = false; - } - for (int slot = 0; slot < kFleetSlotCount; ++slot) - if (fleets[slot]) - Observe(now, slot, fleets[slot]); - else - slots_[slot] = {}; - serviced_ = true; - return true; - } - - void Seed(Clock::time_point now) - { - const auto lifetime = now - seed_started_; - if (now - last_scan_ >= (lifetime < kFleetFastScanPeriod ? kFleetScanInterval : kFleetSlowScanInterval)) { - last_scan_ = now; - const bool available = Scan(now); - // The fleet service went away mid-baseline, as on logout. - if (serviced_ && !available) { - Reset(now); - return; - } - if (available && !observed_) { - observed_ = true; - first_seen_ = last_change_ = now; + auto* fleet = manager->GetFleetPlayerData(slot); + auto& known = slots_[slot]; + if (!fleet) { + if (known.fleet_id) + Moved(now, slot, 0); + known = {}; + continue; } - } - - if (!observed_) { - if (lifetime >= kFleetServiceWait) { - seeding_ = false; - last_discovery_ = now; - logging::Debug("process", "fleets", "no fleet service after {} s; checking every {} s", - kFleetServiceWait.count(), kFleetDiscoveryEvery.count()); + const auto id = fleet->Id(); + const auto state = fleet->CurrentState(); + if (known.fleet_id != id) { + Moved(now, slot, id); + known = {id, state}; + continue; } - return; + if (known.state == state) + continue; + const auto before = std::exchange(known.state, state); + if (!snapshot_at_) + Append(json::array({Record(slot, fleet, state, before)}), false); } - const bool forced = now - first_seen_ >= kFleetStableAtMost; - if (!forced && now - last_change_ < kFleetStableFor) - return; - // One more quiet frame, then a closing scan that must not see a change either. - if (!finalizing_) { - finalizing_ = true; - return; + if (snapshot_at_ && now >= *snapshot_at_) { + snapshot_at_.reset(); + snapshot_requested.store(false, std::memory_order_relaxed); + QueueSnapshot("slots changed"); } - const bool available = Scan(now); - if (!forced && now - last_change_ < kFleetStableFor) { - finalizing_ = false; - return; - } - if (!available) - return; - seeding_ = false; - last_probe_ = now; - logging::Debug("process", "fleets", "stable baseline for {} fleet slots", - std::ranges::count_if(slots_, &Slot::occupied)); - snapshot_requested.store(false, std::memory_order_relaxed); - QueueSnapshot("baseline"); } - // After giving up on the fleet service, looks for it again. - void Discover(Clock::time_point now) - { - if (now - last_discovery_ < kFleetDiscoveryEvery) - return; - last_discovery_ = now; - auto* manager = FleetsManager::Instance(); - if (manager && manager->HasFleetService()) - Restart(now, -1, 0); - } + private: + struct Slot { + int64_t fleet_id = 0; + FleetState state = FleetState::Unknown; + }; - void Probe(Clock::time_point now) + void Moved(Clock::time_point now, int slot, int64_t fleet_id) { - if (now - last_probe_ < kFleetProbeEvery) - return; - last_probe_ = now; - auto* manager = FleetsManager::Instance(); - if (!manager || !manager->HasFleetService()) { - Restart(now, -1, 0); - return; - } - const int slot = probe_slot_; - probe_slot_ = (slot + 1) % kFleetSlotCount; - auto* fleet = manager->GetFleetPlayerData(slot); - if (Moved(slot, fleet)) - Restart(now, slot, fleet ? fleet->Id() : 0); - else if (fleet) - Observe(now, slot, fleet); + logging::Debug("process", "fleets", "slot {} now holds fleet {}; snapshot in {} s", slot, fleet_id, + kFleetSettle.count()); + snapshot_at_ = now + kFleetSettle; } std::array slots_{}; - // seeding_ until a baseline is sent; serviced_ once a scan found the fleet service since the last reset; observed_ - // once the slots were first read; finalizing_ for the frame before the closing scan. - bool seeding_ = true, serviced_ = false, observed_ = false, finalizing_ = false; - Clock::time_point seed_started_{}, first_seen_{}, last_change_{}, last_scan_{}, last_probe_{}, last_discovery_{}; - int probe_slot_ = 0; + Clock::time_point next_poll_{}; + std::optional snapshot_at_; }; FleetWatcher fleet_watcher; @@ -447,7 +325,6 @@ namespace return true; if (!Config::Sync::Get()->options.fleets) return false; - fleet_watcher.Reset(Clock::now()); fleet_sync_installed = true; spdlog::info("[Sync] fleet state sync enabled"); return true; diff --git a/src/mod/version.h b/src/mod/version.h index 40672e537..bee2d137a 100644 --- a/src/mod/version.h +++ b/src/mod/version.h @@ -1,6 +1,6 @@ #pragma once -// The mod's version and build identity, for the logs, the loading screen and the Windows version resource. CI +// The mod's version, for the logs, the loading screen and the Windows version resource. CI // stamps VERSION_COMMIT_HASH, so keep that line's shape. // clang-format off @@ -25,35 +25,6 @@ #define VER_FILE_VERSION VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION, VERSION_PATCH #define VER_FILE_VERSION_STR VERSION_STRINGIFY(VERSION_MAJOR) "." VERSION_STRINGIFY(VERSION_MINOR) "." VERSION_STRINGIFY(VERSION_REVISION) "." VERSION_STRINGIFY(VERSION_PATCH) -#define STFC_DISTRIBUTION_ID "netniv.stfc-community-mod" - -#ifndef STFC_SOURCE_STATE_ID -#define STFC_SOURCE_STATE_ID "unknown" -#endif - -#ifndef STFC_BASE_COMMIT -#define STFC_BASE_COMMIT "unknown" -#endif - -#ifndef STFC_BUILD_INVOCATION_ID -#define STFC_BUILD_INVOCATION_ID "zig-direct" -#endif - -#ifndef STFC_BUILD_MODE -#define STFC_BUILD_MODE "unknown" -#endif - -#ifndef STFC_BUILD_CHANNEL -#define STFC_BUILD_CHANNEL "local" -#endif - -#define STFC_IDENTITY_COMMENT_STR "stfc-identity-v1;distribution=" STFC_DISTRIBUTION_ID \ - ";source=" STFC_SOURCE_STATE_ID \ - ";base=" STFC_BASE_COMMIT \ - ";build=" STFC_BUILD_INVOCATION_ID \ - ";mode=" STFC_BUILD_MODE \ - ";channel=" STFC_BUILD_CHANNEL - #define VER_PRODUCTNAME_STR "STFC: Community Mod" #define VER_PRODUCT_VERSION VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION #define VER_PRODUCT_VERSION_STR VER_FILE_VERSION_STR diff --git a/src/platform/windows/version.rc b/src/platform/windows/version.rc index 17328d83b..7fe9e571a 100644 --- a/src/platform/windows/version.rc +++ b/src/platform/windows/version.rc @@ -2,6 +2,14 @@ // zig/mod.zig passes to the resource compiler. #include +// The stfc-identity-v1 marker. CI and downstream tools parse its fields and their order. +#define STFC_IDENTITY_COMMENT_STR "stfc-identity-v1;distribution=netniv.stfc-community-mod" \ + ";source=" STFC_SOURCE_STATE_ID \ + ";base=" STFC_BASE_COMMIT \ + ";build=" STFC_BUILD_INVOCATION_ID \ + ";mode=" STFC_BUILD_MODE \ + ";channel=" STFC_BUILD_CHANNEL + VS_VERSION_INFO VERSIONINFO FILEVERSION VER_FILE_VERSION PRODUCTVERSION VER_PRODUCT_VERSION diff --git a/tests/action_queue.cc b/tests/action_queue.cc index 1b73e3e0a..2246264e0 100644 --- a/tests/action_queue.cc +++ b/tests/action_queue.cc @@ -1,80 +1,16 @@ -// The Kir'Shara queue fixes' decisions and request store, against fake weak handles. They do not model the game's -// ABI or network timing. +// The Kir'Shara queue fixes' decisions and attempt store. They do not model the game's ABI or network timing. #include "features/action_queue.h" -#include #include -#include #include -#include #include +#include using namespace action_queue; -using namespace std::chrono_literals; namespace { -// The shape of il2cpp::GcHandle, over a map that stands in for the GC. Collect clears every handle to an object. -class FakeHandle -{ -public: - static inline unsigned serial{}; - static inline std::map live; - - FakeHandle() = default; - static FakeHandle Weak(int object) - { - live[++serial] = object; - return FakeHandle(serial); - } - ~FakeHandle() - { Reset(); } - FakeHandle(FakeHandle&& other) noexcept - : id_(std::exchange(other.id_, 0)) - { - } - FakeHandle& operator=(FakeHandle&& other) noexcept - { - if (this != &other) { - Reset(); - id_ = std::exchange(other.id_, 0); - } - return *this; - } - explicit operator bool() const - { return id_ != 0; } - int Get() const - { - auto it = live.find(id_); - return it == live.end() ? 0 : it->second; - } - static void Collect(int object) - { - for (auto& [_, value] : live) - if (value == object) - value = 0; - } - -private: - explicit FakeHandle(unsigned id) - : id_(id) - { - } - void Reset() - { - if (!id_) - return; - // Outside the assert, so the handle is released even when asserts are compiled out. - [[maybe_unused]] const auto erased = live.erase(id_); - assert(erased == 1); - id_ = 0; - } - unsigned id_ = 0; -}; - -using Requests = action_queue::Requests; -const auto now = Clock::time_point{} + 100s; // Fleet 10's queue when it dispatches its attempt on target 100, and after target 100 has gone. const RetryState dispatch{.valid = true, .fleet = 10, .front = 100, .attempt = 2.5f, .count = 2}; const RetryState remaining{.valid = true, .fleet = 10, .front = 200, .attempt = 2.5f, .count = 1}; @@ -86,138 +22,88 @@ template T With(T value, Change change) return value; } -// A queue of `count` targets of which only the head is captured. -QueueSnapshot Queue(int count, std::int64_t head) -{ - QueueSnapshot queue{.present = true, .player_fleet_id = 42, .count = count, .head_target_id = head}; - if (count > 0) { - queue.target_ids[0] = head; - queue.captured_target_count = count; - } - return queue; -} - -// A fully captured queue of `targets`. -QueueSnapshot Queue(std::initializer_list targets) -{ - const auto count = static_cast(targets.size()); - QueueSnapshot queue{.present = true, - .player_fleet_id = 42, - .count = count, - .head_target_id = count > 0 ? *targets.begin() : 0, - .captured_target_count = count}; - std::ranges::copy(targets, queue.target_ids.begin()); - return queue; -} +QueueSnapshot Queue(std::vector targets) +{ return {.present = true, .player_fleet_id = 42, .targets = std::move(targets)}; } void TestRecovery() { - Requests requests; + Attempts attempts; // 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(FakeHandle::live.empty()); + attempts.Remember(3, dispatch); + assert(CanAdvance(remaining, 100)); + assert(attempts.Consume(remaining, 100)); + assert(!attempts.Consume(remaining, 100)); // Native retry remains responsible while the target is still queued, even after reordering. - assert(!CanAdvance(With(remaining, [](auto& s) { s.contains_target = true; }), 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)); - assert(!CanAdvance(With(remaining, [](auto& s) { s.valid = false; }), 10, 100, true, true, true)); - assert(!CanAdvance(With(remaining, [](auto& s) { s.count = 0; }), 10, 100, true, true, true)); - assert(!CanAdvance(With(remaining, [](auto& s) { s.engaging = true; }), 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); - assert(!requests.Consume(1, With(remaining, [](auto& s) { s.attempt = 9; }), 100, now)); - FakeHandle::Collect(1); - assert(!requests.Consume(1, remaining, 100, now)); - assert(FakeHandle::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(FakeHandle::live.empty()); - // Bounded capacity, independent fleets and session cleanup release all handles. - for (int fleet = 1; fleet <= 9; ++fleet) { - requests.Remember(fleet, With(dispatch, [&](auto& s) { s.fleet = fleet; }), now + std::chrono::seconds(fleet)); - assert(FakeHandle::live.size() <= 8); - } - assert(!requests.Consume(1, With(remaining, [](auto& s) { s.fleet = 1; }), 100, now + 10s)); - assert(requests.Consume(9, With(remaining, [](auto& s) { s.fleet = 9; }), 100, now + 10s)); - requests.Clear(); - requests.Clear(); - assert(FakeHandle::live.empty()); - assert(!requests.Consume(2, remaining, 100, now)); + assert(!CanAdvance(With(remaining, [](auto& s) { s.contains_target = true; }), 100)); + assert(!CanAdvance(dispatch, 100)); + // Missing or empty queue, unknown target and ongoing engagement fail closed. + assert(!CanAdvance(remaining, 0)); + assert(!CanAdvance(With(remaining, [](auto& s) { s.valid = false; }), 100)); + assert(!CanAdvance(With(remaining, [](auto& s) { s.count = 0; }), 100)); + assert(!CanAdvance(With(remaining, [](auto& s) { s.engaging = true; }), 100)); + + // A failure only answers the latest attempt of the same fleet, on the same target at the same attempt time. + attempts.Remember(3, dispatch); + assert(!attempts.Consume(With(remaining, [](auto& s) { s.fleet = 11; }), 100)); + attempts.Remember(3, With(dispatch, [](auto& s) { + s.front = 300; + s.attempt = 3; + })); + assert(!attempts.Consume(remaining, 100)); + attempts.Remember(3, dispatch); + assert(!attempts.Consume(With(remaining, [](auto& s) { s.attempt = 9; }), 100)); + // An attempt without a readable head clears the slot rather than leave an older attempt to answer for it. + attempts.Remember(3, dispatch); + attempts.Remember(3, With(dispatch, [](auto& s) { s.valid = false; })); + assert(!attempts.Consume(remaining, 100)); + + // An attempt that did not dispatch is forgotten. + attempts.Remember(3, dispatch); + attempts.Forget(3); + assert(!attempts.Consume(remaining, 100)); + + // Fleets keep their own slots; slots past the dock count are ignored. + for (int slot = 0; slot < Attempts::kSlots; ++slot) + attempts.Remember(slot, With(dispatch, [&](auto& s) { s.fleet = 1000 + slot; })); + attempts.Remember(Attempts::kSlots, dispatch); + attempts.Remember(-1, dispatch); + assert(!attempts.Consume(remaining, 100)); + for (int slot = 0; slot < Attempts::kSlots; ++slot) + assert(attempts.Consume(With(remaining, [&](auto& s) { s.fleet = 1000 + slot; }), 100)); } void TestThinProtection() { - // Native engage results have stable diagnostic names. - static_assert(EngageResultName(0) == "success"); - static_assert(EngageResultName(1) == "skip-target"); - static_assert(EngageResultName(2) == "stop"); - static_assert(EngageResultName(-1) == "not-attempted"); - static_assert(EngageResultName(99) == "unknown"); - - // Destroyed-head processing is exact and gated on the setting. + // Destroyed-head processing is exact. { - const auto queue = Queue(2, 101); + const auto queue = Queue({101, 202}); - assert(ShouldProcessDestroyedHead(true, true, 101, queue)); - assert(!ShouldProcessDestroyedHead(false, true, 101, queue)); - assert(!ShouldProcessDestroyedHead(true, false, 101, queue)); - assert(!ShouldProcessDestroyedHead(true, true, 202, queue)); - assert(!ShouldProcessDestroyedHead(true, true, 0, queue)); + assert(ShouldProcessDestroyedHead(101, queue)); + assert(!ShouldProcessDestroyedHead(202, queue)); + assert(!ShouldProcessDestroyedHead(0, queue)); } // Destroyed-head processing refuses active queue latches. { - auto queue = Queue(2, 101); + auto queue = Queue({101, 202}); queue.is_engaging = true; - assert(!ShouldProcessDestroyedHead(true, true, 101, queue)); + assert(!ShouldProcessDestroyedHead(101, queue)); queue.is_engaging = false; queue.last_engaged_target_id = 101; - assert(!ShouldProcessDestroyedHead(true, true, 101, queue)); + assert(!ShouldProcessDestroyedHead(101, queue)); queue.last_engaged_target_id = 0; queue.pending_target_id = 101; - assert(!ShouldProcessDestroyedHead(true, true, 101, queue)); + assert(!ShouldProcessDestroyedHead(101, queue)); } // A local kill and repeated disposal are no-ops once the exact target is absent. { - const auto next_head = Queue(1, 202); - const auto empty = Queue(0, 0); - - assert(!ShouldProcessDestroyedHead(true, true, 101, next_head)); - assert(!ShouldProcessDestroyedHead(true, true, 101, empty)); + assert(!ShouldProcessDestroyedHead(101, Queue({202}))); + assert(!ShouldProcessDestroyedHead(101, Queue({}))); } // A native prune leaves a stranded new head to resume. @@ -225,7 +111,7 @@ void TestThinProtection() const auto before = Queue({101, 202, 303, 404}); const auto after = Queue({303, 404}); - assert(IsNativePruneResumeCandidate(true, true, before, after)); + assert(IsNativePruneResumeCandidate(true, before, after)); } // Temporary unavailability without a native removal is not a candidate. @@ -233,7 +119,7 @@ void TestThinProtection() const auto before = Queue({101, 202}); const auto after = Queue({101, 202}); - assert(!IsNativePruneResumeCandidate(true, true, before, after)); + assert(!IsNativePruneResumeCandidate(true, before, after)); } // Resuming requires an idle fleet and rejects latches on the surviving suffix. @@ -241,19 +127,18 @@ void TestThinProtection() const auto before = Queue({101, 202, 303}); auto after = Queue({202, 303}); - assert(!IsNativePruneResumeCandidate(false, true, before, after)); - assert(!IsNativePruneResumeCandidate(true, false, before, after)); + assert(!IsNativePruneResumeCandidate(false, before, after)); after.is_engaging = true; - assert(!IsNativePruneResumeCandidate(true, true, before, after)); + assert(!IsNativePruneResumeCandidate(true, before, after)); after.is_engaging = false; after.last_engaged_target_id = 202; - assert(!IsNativePruneResumeCandidate(true, true, before, after)); + assert(!IsNativePruneResumeCandidate(true, before, after)); after.last_engaged_target_id = 0; after.pending_target_id = 202; - assert(!IsNativePruneResumeCandidate(true, true, before, after)); + assert(!IsNativePruneResumeCandidate(true, before, after)); } // Resuming accepts no-target sentinels and stale latches from the removed prefix. @@ -263,11 +148,11 @@ void TestThinProtection() auto after = Queue({303, 404}); after.last_engaged_target_id = 101; after.pending_target_id = 202; - assert(IsNativePruneResumeCandidate(true, true, before, after)); + assert(IsNativePruneResumeCandidate(true, before, after)); after.last_engaged_target_id = -1; after.pending_target_id = -1; - assert(IsNativePruneResumeCandidate(true, true, before, after)); + assert(IsNativePruneResumeCandidate(true, before, after)); } // Resuming rejects unrelated positive native latches. @@ -276,80 +161,35 @@ void TestThinProtection() auto after = Queue({303}); after.pending_target_id = 909; - assert(!IsNativePruneResumeCandidate(true, true, before, after)); + assert(!IsNativePruneResumeCandidate(true, before, after)); after.pending_target_id = 0; after.last_engaged_target_id = 909; - assert(!IsNativePruneResumeCandidate(true, true, before, after)); + assert(!IsNativePruneResumeCandidate(true, before, after)); } // A manual clear and repeated watchdog callbacks are no-ops. { const auto before = Queue({101, 202}); - const auto empty = Queue(0, 0); const auto stable = Queue({202}); - assert(!IsNativePruneResumeCandidate(true, true, before, empty)); - assert(!IsNativePruneResumeCandidate(true, true, stable, stable)); + assert(!IsNativePruneResumeCandidate(true, before, Queue({}))); + assert(!IsNativePruneResumeCandidate(true, stable, stable)); } - // Resuming fails closed on reordering, truncation and fleet replacement. + // Resuming fails closed on reordering and fleet replacement, and reads queues of any length. { const auto before = Queue({101, 202, 303, 404}); auto after = Queue({404, 303}); - assert(!IsNativePruneResumeCandidate(true, true, before, after)); - - after = Queue({303, 404}); - after.targets_truncated = true; - assert(!IsNativePruneResumeCandidate(true, true, before, after)); + assert(!IsNativePruneResumeCandidate(true, before, after)); after = Queue({303, 404}); after.player_fleet_id = 84; - assert(!IsNativePruneResumeCandidate(true, true, before, after)); - } - - // The resume postcondition requires the exact same idle surviving suffix. - { - const auto expected = Queue({303, 404}); - - assert(IsStableResumePostcondition(expected, expected)); - - auto confirmed = expected; - confirmed.target_ids[1] = 505; - assert(!IsStableResumePostcondition(expected, confirmed)); - - confirmed = expected; - confirmed.is_engaging = true; - assert(!IsStableResumePostcondition(expected, confirmed)); - - confirmed = expected; - confirmed.pending_target_id = 303; - assert(!IsStableResumePostcondition(expected, confirmed)); - - auto stale_latch = expected; - stale_latch.last_engaged_target_id = 101; - stale_latch.pending_target_id = 202; - assert(IsStableResumePostcondition(stale_latch, stale_latch)); - - confirmed = stale_latch; - confirmed.pending_target_id = 101; - assert(!IsStableResumePostcondition(stale_latch, confirmed)); - - stale_latch.pending_target_id = 303; - assert(!IsStableResumePostcondition(stale_latch, stale_latch)); - - confirmed = expected; - confirmed.player_fleet_id = 84; - assert(!IsStableResumePostcondition(expected, confirmed)); + assert(!IsNativePruneResumeCandidate(true, before, after)); - confirmed = expected; - confirmed.count = 1; - confirmed.head_target_id = 404; - confirmed.target_ids[0] = 404; - confirmed.target_ids[1] = 0; - confirmed.captured_target_count = 1; - assert(!IsStableResumePostcondition(expected, confirmed)); + const auto long_before = Queue({1, 2, 3, 4, 5, 6, 7, 8, 9, 10}); + assert(IsNativePruneResumeCandidate(true, long_before, Queue({3, 4, 5, 6, 7, 8, 9, 10}))); } } @@ -359,17 +199,13 @@ void TestAddressGuard() const Address system{.valid = true, .galaxy = 0, .system = 925162490, .planet = 0, .instance = 710}; // The reproduced case: an impulsing fleet reported at its starbase planet, in another system, while its deployment // and its target are both still in space. - const AddressEvidence reproduced{.enabled = true, - .player_state_event = true, - .owned_queue = true, - .owned_deployment = true, - .planning_recall = false, - .player_state = kFleetImpulsing, - .deployed_state = kDeployedMoving, - .removal_reason = 0, - .reported = base, - .deployed = system, - .target = system}; + const AddressEvidence reproduced{.planning_recall = false, + .player_state = kFleetImpulsing, + .deployed_state = kDeployedMoving, + .removal_reason = 0, + .reported = base, + .deployed = system, + .target = system}; assert(UseDeployedAddress(reproduced)); for (int state : {1, 64, 512}) assert(UseDeployedAddress(With(reproduced, [&](auto& e) { e.player_state = state; }))); @@ -382,10 +218,6 @@ void TestAddressGuard() assert(!UseDeployedAddress(With(reproduced, [&](auto& e) { e.removal_reason = reason; }))); const auto rejected = [&](auto change) { return !UseDeployedAddress(With(reproduced, change)); }; - assert(rejected([](auto& e) { e.enabled = false; })); - assert(rejected([](auto& e) { e.player_state_event = false; })); // manual clear/UI path - assert(rejected([](auto& e) { e.owned_queue = false; })); - assert(rejected([](auto& e) { e.owned_deployment = false; })); // wrong fleet or unknown ownership assert(rejected([](auto& e) { e.planning_recall = true; })); assert(rejected([](auto& e) { e.reported.valid = false; })); assert(rejected([](auto& e) { e.deployed.valid = false; })); diff --git a/tests/il2cpp_bindings.cc b/tests/il2cpp_bindings.cc index b0eb088c4..bcfbec87a 100644 --- a/tests/il2cpp_bindings.cc +++ b/tests/il2cpp_bindings.cc @@ -828,15 +828,6 @@ int main() Require(initialized.empty(), "a static hook lookup leaves the class uninitialized"); Require(!Base::Class::StaticMethod("Select"), "static hook lookup rejects an instance method"); - // Boxed primitives unbox only as their own type. - Il2CppObject boxed{}; - boxed.klass = &int_class.klass; - int32_t number = 0; - int64_t wide = 7; - Require(il2cpp::Unbox(&boxed, number) && number == shared_result, "unbox the boxed type"); - Require(!il2cpp::Unbox(&boxed, wide) && wide == 7, "unbox rejects another type and leaves the value"); - Require(!il2cpp::Unbox(static_cast(nullptr), number), "unbox rejects null"); - // A hook set installs every hook or none: a missing member, or a null address, leaves its targets untouched. { const int scaled_before = Scaled(2); @@ -880,15 +871,6 @@ int main() for (auto* str : {mixed, lone, ascii}) std::free(str); - // TryCall tells a failed call apart from a result that reads as zero. - Require(!base->TryCall<"Explode", int32_t()>() && ErrorsMentioning("Game.Base.Explode threw") == 1, - "tried exception is empty and reported once"); - base->SetCount(0); - const auto zero = base->TryCall<"get_Count", int32_t()>(); - Require(zero && *zero == 0, "tried zero result is known"); - Require(base->TryCall<"Select", void(int32_t)>(5) && seen_int == 5, "tried void call"); - Require(!base->TryCall<"Vanished", int32_t()>(), "tried missing method is empty"); - KeyboardSnapshot(); InputFocus(); std::cout << "IL2CPP binding tests passed\n"; diff --git a/tests/il2cpp_gc.cc b/tests/il2cpp_gc.cc index 610cb5811..a4a65a40e 100644 --- a/tests/il2cpp_gc.cc +++ b/tests/il2cpp_gc.cc @@ -79,15 +79,5 @@ int main() Require(root && !root.Get()); } Require(frees == 3 && targets.empty()); - { - // Operation roots keep what they rooted until the set ends; a failed root marks the set. - il2cpp::GcRoots roots; - Require(!roots.Keep(nullptr) && roots.healthy()); - Require(roots.Keep(&object) == &object && roots.Keep(&other) == &other && targets.size() == 2); - fail_allocation = true; - Require(!roots.Keep(&object) && !roots.healthy()); - fail_allocation = false; - } - Require(frees == 5 && targets.empty()); std::cout << "IL2CPP GC ownership tests passed\n"; } diff --git a/tests/mod_logic.cc b/tests/mod_logic.cc index 402e2d677..f63d93826 100644 --- a/tests/mod_logic.cc +++ b/tests/mod_logic.cc @@ -53,7 +53,6 @@ std::string Value(const toml::table& parsed, std::string_view section, std::stri void ShipHints() { - Shortcut::Set({}); // A locked dock F leaves native G at the sixth selectable position (the reported 1,2,3,4,5,7 layout). const std::array gapped_fleets{0, 1, 2, 3, 4, 6}; auto ship_hint = [&](Action action) { @@ -63,14 +62,6 @@ void ShipHints() "Native ship G must advertise the sixth mod shortcut when dock F is locked"); Check(ship_hint(Action::kSelectShip5) == Action::kSelectShip5, "Earlier ship hints must stay unchanged"); Check(ship_hint(Action::kSelectShip6) == Action::kCount, "Missing dock must not advertise a shortcut"); - Check(ship_hint(Action::kShowGalaxy) == Action::kShowGalaxy, "Non-ship hint mapping changed"); - Check(ShipShortcutHintAction(Action::kSelectShip1, 0, [](std::size_t) { return 0; }) == Action::kCount, - "Unavailable fleet context must not advertise a guessed ship shortcut"); - for (int index = 0; index < 8; ++index) { - const auto action = static_cast(std::to_underlying(Action::kSelectShip1) + index); - Check(ShipShortcutHintAction(action, 8, [](std::size_t i) { return static_cast(i); }) == action, - "Contiguous eight-dock hints must keep their original shortcuts"); - } const std::array reordered_fleets{7, 0, 2, -1, 6, 4, 1, 3, 5}; auto reordered_hint = [&](Action action) { return ShipShortcutHintAction(action, reordered_fleets.size(), [&](std::size_t i) { return reordered_fleets[i]; }); @@ -80,8 +71,6 @@ void ShipHints() "Null fleet entries must preserve the positions passed to RequestSelect"); Check(reordered_hint(Action::kSelectShip6) == Action::kCount, "Fleet beyond the eight supported mod shortcuts must not advertise a shortcut"); - Check(Shortcut::Hint(ship_hint(Action::kSelectShip7)).empty(), - "Unbound ship selection must not advertise a working shortcut"); Shortcut::Bindings custom{}; custom[std::to_underlying(Action::kSelectShip6)].push_back(KeyChord::Parse("CTRL-F8")); Shortcut::Set(std::move(custom)); @@ -129,7 +118,7 @@ void ToastText() Check(StripRichText("a < b") == "a < b", "an unclosed '<' is text"); Check(TitleCase("USS ENTERPRISE-D") == "Uss Enterprise-D", "title case"); Check(HullKeyName("Hull_L30_Destroyer_Klingon_LIVE") == "Lv.30 Destroyer Klingon", "hull key with level"); - Check(HullKeyName("Hull_BORG_CUBE") == "Borg Cube", "hull key without level"); + Check(HullKeyName("Hull_BORG_CUBE") == "BORG CUBE", "hull key without level"); } void ShipNames() @@ -194,6 +183,10 @@ void DefaultBindings() Check(!parsed["shortcuts"][key.str()], "optional shortcut gates stay unchanged"); } } + // Every shortcut's default is pinned here, so the parser never meets a default without a valid chord. + for (const auto& [key, value] : *parsed["shortcuts"].as_table()) + Check(fixture["always"][key] || fixture["extended"][key] || fixture["experimental"][key], + "every shortcut has a default in shortcut_defaults.toml"); } } @@ -212,7 +205,7 @@ void Loading() Check(Value(parsed, "shortcuts", "show_galaxy") == "CTRL-G | F8", "vars records the bindings"); Check(Value(parsed, "shortcuts_source", "show_galaxy") == "config", "source is the settings file"); - Check(!Shortcut::HasBinding(Action::kShowSystem), "an empty string disables the binding"); + Check(Shortcut::Describe(Action::kShowSystem).empty(), "an empty string disables the binding"); Check(Value(parsed, "shortcuts", "show_system") == "NONE", "disabled binding recorded as NONE"); Check(Shortcut::Describe(Action::kShowEvents) == std::string("SHIFT-E"), @@ -223,12 +216,12 @@ void Loading() Check(Shortcut::Describe(Action::kDisableHotKeys) == "F3", "deprecated alias still binds"); Check(Value(parsed, "shortcuts_source", "set_hotkeys_disabled") == "alias:set_hotkeys_disble", "alias source"); - Check(Shortcut::HasBinding(Action::kShowBookmarks), "extended shortcuts load when extended"); + Check(!Shortcut::Describe(Action::kShowBookmarks).empty(), "extended shortcuts load when extended"); parsed = Load(""); Check(Shortcut::Describe(Action::kQuit) == "F10", "loading again replaces earlier bindings"); - Check(!Shortcut::HasBinding(Action::kShowLookup), "experimental shortcuts need experimental"); + Check(Shortcut::Describe(Action::kShowLookup).empty(), "experimental shortcuts need experimental"); parsed = Load("", false); - Check(!Shortcut::HasBinding(Action::kShowBookmarks), "extended shortcuts are skipped otherwise"); + Check(Shortcut::Describe(Action::kShowBookmarks).empty(), "extended shortcuts are skipped otherwise"); Check(Value(parsed, "shortcuts", "show_bookmarks") == "", "skipped shortcuts are not recorded"); } @@ -338,9 +331,6 @@ void NotificationAudio() effective, false); Check(config->audio.alert_defeat == expected, "legacy cue aliases remain valid, unknown names fall back to none"); } - Check(NotificationSoundBytes(NotificationSound::kCount).empty() - && NotificationSoundBytes(static_cast(255)).empty(), - "invalid cues have no WAV data"); } void DailyFactionSettings() @@ -482,19 +472,29 @@ void Matching() Hold({unity::KeyCode::LeftControl, unity::KeyCode::PageUp}); Check(Shortcut::IsPressed(Action::kUiShipScaleUp), "modified chord"); Check(!Shortcut::IsPressed(Action::kUiScaleUp), "plain chord blocked while modified"); - Check(Shortcut::Owns(unity::KeyCode::PageUp), "the modified chord owns its key"); Hold({unity::KeyCode::PageUp}); Check(!Shortcut::IsPressed(Action::kUiScaleUp), "plain chord waits for the key's release"); Hold({}); - Check(!Shortcut::Owns(unity::KeyCode::PageUp), "releasing the key frees it"); + Check(!Shortcut::IsPressed(Action::kUiScaleUp), "released key"); Hold({unity::KeyCode::PageUp}); Check(Shortcut::IsPressed(Action::kUiScaleUp), "plain chord after a release"); - // A modified movement binding keeps moving while its modifier is held. - Load("[shortcuts]\nmove_down = \"CTRL-DOWN\""); - Hold({unity::KeyCode::LeftControl, unity::KeyCode::DownArrow}); - Check(Shortcut::IsDown(Action::kMoveDown) && Shortcut::IsDown(Action::kMoveDown), - "explicit movement chord keeps matching"); + // Keyboard panning reads only the move bindings. A modified move binding pans while held, even after the screen + // shortcuts matched it earlier in the frame. + Load("[shortcuts]\nmove_left = \"CTRL-LEFT\""); + Hold({unity::KeyCode::LeftControl, unity::KeyCode::LeftArrow}); + Check(Shortcut::IsDown(Action::kMoveLeft), "modified movement chord"); + Check(Shortcut::IsPressed(Action::kMoveLeft), "a modified movement chord keeps panning once matched"); + + // An arrow held for another CTRL-arrow chord does not pan, even after CTRL is released; W/A/S/D still do. + Load("[shortcuts]\nui_scaleup = \"CTRL-LEFT\""); // move_left = "A|LEFT" + Hold({unity::KeyCode::LeftControl, unity::KeyCode::LeftArrow}); + Check(Shortcut::IsPressed(Action::kUiScaleUp) && !Shortcut::IsPressed(Action::kMoveLeft), + "a CTRL-arrow chord does not pan"); + Hold({unity::KeyCode::LeftArrow}); + Check(!Shortcut::IsPressed(Action::kMoveLeft), "its arrow pans only after a release"); + Hold({unity::KeyCode::LeftArrow, unity::KeyCode::A}); + Check(Shortcut::IsPressed(Action::kMoveLeft), "other movement keys still pan"); held.fill(false); } } // namespace diff --git a/zig/mod.zig b/zig/mod.zig index d87c1874c..0b8f5eefa 100644 --- a/zig/mod.zig +++ b/zig/mod.zig @@ -98,11 +98,10 @@ pub fn add(ctx: *const Ctx, libs: []const Lib) void { if (ctx.isWindows()) { // version.dll proxies the system DLL: the .def forwards its exports, the .rc carries the build identity. library.win32_module_definition = b.path("src/platform/windows/version.def"); - const rc_flags = addBuildIdentity(ctx, entry); entry.addIncludePath(b.path("src/platform/windows")); entry.addWin32ResourceFile(.{ .file = b.path("src/platform/windows/version.rc"), - .flags = rc_flags, + .flags = identityDefines(ctx), .include_paths = &.{ b.path("src/mod"), b.path("src/platform/windows") }, }); inline for (.{ "shell32", "user32", "ole32", "oleaut32" }) |lib| entry.linkSystemLibrary(lib, .{}); @@ -136,37 +135,28 @@ fn addModSources(ctx: *const Ctx, module: *std.Build.Module, flags: []const []co } } -/// Define the build identity as C++ string macros and as version resource defines. Returns the resource flags. -fn addBuildIdentity(ctx: *const Ctx, module: *std.Build.Module) []const []const u8 { +/// Resource compiler defines for the build identity values. version.rc composes them into the stfc-identity-v1 +/// marker; the RC's /D cuts a value at '=', so the whole marker cannot be passed as one define. +fn identityDefines(ctx: *const Ctx) []const []const u8 { const b = ctx.b; - var defines: [5][2][]const u8 = undefined; - // Each value comes from -D