From 8beb737dce5fa47bf04d53258740c4fdd9bbc322 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 12 Nov 2026 14:45:00 -0700 Subject: [PATCH 1/5] Simplify the Alliance Games Store The store's cards carry only a plain tooltip trigger and no action locale assets, so their tooltip widget has nothing to put Research actions on. The first time a card binds in the Alliance Games store, it now gets an action trigger with the plain one's appearance and the assets of a loaded widget, and the widget's own code picks the actions on every later bind. Nothing tracks cards, and nothing is restored. The store's context handler is an empty override, so it never rebuilds the currency list or rebinds cards that spawn after a regeneration. After each regeneration, once the scroller stops spawning, the store's currency list is rebuilt and the cards in view are bound at the current position. TryCall and GcRoots lose their only caller and are removed with their tests. --- docs/IL2CPP.md | 4 - src/libs/il2cpp/include/il2cpp/il2cpp.h | 72 ---- src/mod/features/alliance_store.cc | 529 +++--------------------- src/mod/game/app.h | 15 - src/mod/game/inventory.h | 268 ++++-------- tests/il2cpp_bindings.cc | 9 - tests/il2cpp_gc.cc | 10 - 7 files changed, 144 insertions(+), 763 deletions(-) diff --git a/docs/IL2CPP.md b/docs/IL2CPP.md index 318ac174a..643d732e9 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. @@ -189,8 +187,6 @@ Change hidden `MethodInfo*` handling only when runtime evidence points to it. 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..e913e0e06 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 { @@ -287,34 +285,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 +308,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 +480,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 { @@ -796,34 +752,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/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/game/app.h b/src/mod/game/app.h index 71c1d5139..63732f0a5 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,18 +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">; @@ -247,8 +234,6 @@ struct Hub { { 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/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/tests/il2cpp_bindings.cc b/tests/il2cpp_bindings.cc index b0eb088c4..6c01db5cc 100644 --- a/tests/il2cpp_bindings.cc +++ b/tests/il2cpp_bindings.cc @@ -880,15 +880,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"; } From eed5a9161932eb21eafb68e975970aa09101407c Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 12 Nov 2026 15:05:21 -0700 Subject: [PATCH 2/5] Simplify the queue fixes and fleet watch Faster queue recovery keeps one attempt per dock slot: the fleet, the head it went for and the queue's attempt time. Those three name the attempt, so the weak queue handles, serials, 30 s expiry, mutex, session-end hook and toggle reset go. The hooks run on the game thread like every Config::Get() caller. The course response handler now only notes whether a failed response's queue was engaging; the fleet, queue and target it compared restate the game's own lookup, and il2cpp::Unbox loses its only caller. Thin queue protection reads the whole queue instead of eight targets, so a longer queue is no longer ineligible; the game bounds its length. The second snapshot before resuming read the same state in the same hook and is gone, as are parameters every caller passed as true and the engage result name table. The address guard no longer re-checks that the fleet owns a non-empty queue or that the deployment looked up by its id has its id. The native check walks that fleet's own queue. Three bindings used only for it go. Fleet sync reads every slot once a second. A slot that fills, empties or changes hands schedules a snapshot after five quiet seconds; until then state changes ride in that snapshot. This replaces the seed, discovery and probe modes and their nine flags. Fleet labels drop their installed flag, since a set that did not commit tracks nothing, and no longer require NavigationZoom.Update, which camera.cc stages and reports itself. --- CONTRIBUTING.md | 2 +- docs/IL2CPP.md | 2 +- src/libs/il2cpp/include/il2cpp/il2cpp.h | 9 - src/mod/features/action_queue.cc | 164 ++++-------- src/mod/features/action_queue.h | 207 +++++---------- src/mod/features/features.h | 2 - src/mod/features/fleet_actions.cc | 1 - src/mod/features/fleet_labels.cc | 20 +- src/mod/game/fleet.h | 12 - src/mod/sync/fleets.cc | 219 ++++------------ tests/action_queue.cc | 328 ++++++------------------ tests/il2cpp_bindings.cc | 9 - 12 files changed, 239 insertions(+), 736 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d63523b58..2fc56dee6 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. diff --git a/docs/IL2CPP.md b/docs/IL2CPP.md index 643d732e9..058a9eb5e 100644 --- a/docs/IL2CPP.md +++ b/docs/IL2CPP.md @@ -178,7 +178,7 @@ 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 | diff --git a/src/libs/il2cpp/include/il2cpp/il2cpp.h b/src/libs/il2cpp/include/il2cpp/il2cpp.h index e913e0e06..38ab630e5 100644 --- a/src/libs/il2cpp/include/il2cpp/il2cpp.h +++ b/src/libs/il2cpp/include/il2cpp/il2cpp.h @@ -529,15 +529,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; 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/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/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/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/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 6c01db5cc..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); From fc0776e1e3effa618764fe48abe03915b52d3152 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 12 Nov 2026 15:04:17 -0700 Subject: [PATCH 3/5] Drop redundant checks from the HUD and camera features The anomaly timer looks up the navigation HUD on each tick through ScreenManager::View, which already returns only a live HUD on a visible canvas, so the held handle, the rediscovery throttle and the replacement logic go. The panel lives under the alliance drawer and is hidden rather than destroyed; Unity tears it down with the drawer and the next tick builds it again. The clean-up branches, the warning and its back-off, the hand-rolled clock and the installer go with it, and unity::Destroy loses its only user. Haven's road bias is one pass every 5 s over the visuals holder, with no stored material handles, owner handle or dedupe set. The road shader defines both offsets, so HasProperty goes, and the zoom hook no longer re-checks finiteness, aliasing and ranges of values the game and the settings reader already guarantee. Backdrop scaling compares the remembered scale exactly, so an authored scale reloaded from a cache never matches and is scaled again. The authored flag, the revision counter and the popData bindings that worked around the old 0.1 tolerance go. The instant cargo counter decides on the bar's data class alone. The X-of-Y label identifier is shared with other bars, so its check and the warning it caused go. --- src/libs/unity/include/unity/core.h | 8 - src/mod/features/anomaly_timer.cc | 280 ++++++++++------------------ src/mod/features/camera.cc | 42 ++--- src/mod/features/cargo.cc | 21 +-- src/mod/features/haven.cc | 135 ++++---------- src/mod/features/instant_warp.cc | 2 - src/mod/game/navigation.h | 10 - src/mod/game/ui.h | 5 +- 8 files changed, 148 insertions(+), 355 deletions(-) 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/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..699fa76e0 100644 --- a/src/mod/features/camera.cc +++ b/src/mod/features/camera.cc @@ -194,16 +194,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 +211,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 +223,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,34 +235,21 @@ 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 --- 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/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/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*>(); } From be81dba4520f31d5c67867bd16e520f2879ff1a8 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 12 Nov 2026 15:05:21 -0700 Subject: [PATCH 4/5] Trim the shortcut and keybind badge checks A modified movement binding such as move_left = "CTRL-LEFT" pans again. The pan hook switched keyboard panning off while any arrow was owned by a modified chord, and the screen shortcuts claim the arrow for that very binding earlier in the frame. Shortcut matching already keeps a plain move binding waiting while its arrow belongs to a CTRL-arrow chord, so the hook's own arrow check and Shortcut::Owns go. That per-frame Owns call was also what forgot released keys outside a plain key press. Matching now prunes them itself, so CTRL-PGUP followed by a fresh PGUP works in every view, not only where the pan hook runs. - The pan gate is ShortcutsManager.CanUseShortcuts alone. The game's gate already checks the tutorial input lock that TutorialManager.IsSceneInputAllowed reads, and its own text focus check, so the tutorial binding goes. - The keybind badges install with the hotkey hooks, whatever toggle_shortcut_hints is bound to. Only [patches] decides which hooks install. HasBinding loses its last caller. - A shortcut default that fails to parse is no longer handled at runtime; the defaults fixture pins every shortcut to a valid chord and now fails on one missing from it. - The badge table's compile-time self-checks, the toggle's warning for a missing shortcut manager, the one-use camera helper and the ship badge test cases that repeat the gap and reorder cases go. --- src/mod/features/camera.cc | 34 ++++++--------------- src/mod/features/keybind_badges.cc | 29 +++--------------- src/mod/game/app.h | 9 ------ src/mod/shortcuts.cc | 34 ++++++--------------- src/mod/shortcuts.h | 3 -- tests/mod_logic.cc | 47 ++++++++++++++++-------------- 6 files changed, 47 insertions(+), 109 deletions(-) diff --git a/src/mod/features/camera.cc b/src/mod/features/camera.cc index 699fa76e0..1da7bc3e8 100644 --- a/src/mod/features/camera.cc +++ b/src/mod/features/camera.cc @@ -19,7 +19,6 @@ #include #include -#include #include #include #include @@ -254,24 +253,9 @@ void PlanetViewUtils_Populate_Hook(auto original, PlanetViewUtils* _this, Il2Cpp // --- 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. @@ -283,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. @@ -301,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)); @@ -335,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/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/game/app.h b/src/mod/game/app.h index 63732f0a5..fbc36495f 100644 --- a/src/mod/game/app.h +++ b/src/mod/game/app.h @@ -217,13 +217,6 @@ struct Model : il2cpp::Object { { return Field<"_appConfig", AppConfig*>(); } }; -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">; @@ -232,8 +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 SectionID CurrentSection() { 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/tests/mod_logic.cc b/tests/mod_logic.cc index 402e2d677..90d8f56a2 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)); @@ -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"); } @@ -482,19 +475,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 From 2f6ff5cb6dc110178af4e23d30e62d0aa502c8e0 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 12 Nov 2026 15:21:45 -0700 Subject: [PATCH 5/5] Trim toast, audio and build identity extras Toast text reads go straight through the bindings. il2cpp::Guarded caught Windows access violations from raw offset reads, but every read now goes through named, kind-checked bindings that report a missing member and read it as zero, and macOS already ran the same code unguarded. Guarded, its fault report and its doc row go. Notify no longer handles a toast state without a title: settings only yield catalog states and the title table names all of them. Hull key names are title-cased once, by the battle body. The notification cue cache drops its once_flags and range check. Only the toast hooks call it, on the game thread, and the cue always comes from the settings catalog. The test that fed it out-of-range cues goes with them. The macOS player drops an ARC branch; the build never enables ARC. Build identity values reach only the resource compiler, as plain -D options; version.rc composes the unchanged stfc-identity-v1 marker. The C++ macros, the never-used header defaults and the environment fallbacks, which made Zig 0.17 reconfigure on every run, are removed. CI checks the full marker with verify-windows-binary.py --identity in the build job, so the attest job only downloads and attests the DLL that job built. FEATURES.md no longer limits chest-purchase sliders to Windows. --- .github/workflows/ci.yaml | 38 +-------- CONTRIBUTING.md | 15 +--- docs/FEATURES.md | 2 +- docs/IL2CPP.md | 1 - src/libs/il2cpp/include/il2cpp/il2cpp.h | 21 ----- src/libs/il2cpp/src/il2cpp.cc | 3 - src/mod/features/notification_audio.cc | 11 ++- src/mod/features/notification_audio_mac.mm | 2 - src/mod/features/toast_text.h | 4 +- src/mod/features/toasts.cc | 91 +++++++--------------- src/mod/version.h | 31 +------- src/platform/windows/version.rc | 8 ++ tests/mod_logic.cc | 5 +- zig/mod.zig | 36 ++++----- 14 files changed, 67 insertions(+), 201 deletions(-) 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 2fc56dee6..435811c5c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 058a9eb5e..626a03e9e 100644 --- a/docs/IL2CPP.md +++ b/docs/IL2CPP.md @@ -183,7 +183,6 @@ Change hidden `MethodInfo*` handling only when runtime evidence points to it. | `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. diff --git a/src/libs/il2cpp/include/il2cpp/il2cpp.h b/src/libs/il2cpp/include/il2cpp/il2cpp.h index 38ab630e5..6f51775f1 100644 --- a/src/libs/il2cpp/include/il2cpp/il2cpp.h +++ b/src/libs/il2cpp/include/il2cpp/il2cpp.h @@ -91,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 @@ -675,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. 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/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/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/mod_logic.cc b/tests/mod_logic.cc index 90d8f56a2..f63d93826 100644 --- a/tests/mod_logic.cc +++ b/tests/mod_logic.cc @@ -118,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() @@ -331,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() 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