diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 3898eaa66..6f83750f3 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -107,6 +107,13 @@ Proxy, certificate verification, name-cache, and diagnostic options are in the s ## Compatibility switches +During an in-process game restart, including one initiated by the server, the mod pauses its frame work and +shortcuts while the game continues updating. Pending UI actions and session-owned UI references are cleared. +Mod frame work resumes after the game has left login and section changes have settled for three seconds. +The log records these steps under `[Reload]`, warns once if readiness takes over a minute, and records the first +system or galaxy view reached afterward. These guards reduce interference during reload; they do not repair a +failed game connection or asset download. + `[patches]` keys disable hook groups in every build. Changes require a restart. Use them to isolate a broken hook or a conflict with another mod. Normal feature settings remain separate and can change during play. diff --git a/src/mod/features/action_queue.cc b/src/mod/features/action_queue.cc index 9d2589fd3..f2fe63e7e 100644 --- a/src/mod/features/action_queue.cc +++ b/src/mod/features/action_queue.cc @@ -10,6 +10,7 @@ #include "features/action_queue.h" #include "config.h" #include "features/features.h" +#include "mod.h" #include "game/queues.h" @@ -27,7 +28,7 @@ namespace bool QueueFixesEnabled() { const auto& control = Config::Get().control; - return control.kirshara_fixes && control.queue_enabled; + return !ModReloading() && control.kirshara_fixes && control.queue_enabled; } // Sets a thread-local for the length of one native call, so hooks the game reaches from inside it can see it. @@ -83,6 +84,9 @@ action_queue::QueueSnapshot SnapshotOf(ActionQueueInstance* queue) action_queue::Attempts recovery_attempts; +void ResetActionQueueRecovery() +{ recovery_attempts = {}; } + // 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; @@ -150,7 +154,7 @@ bool ActionQueueManager_ShouldRetryFailedSetCourse_Hook(auto original, ActionQue ActionQueueInstance* queue) { const bool retry = original(_this, target, queue); - if (retry || !failed_while_engaging) + if (ModReloading() || 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 @@ -188,7 +192,7 @@ bool ResumeAfterPrune(ActionQueueManager* manager, ActionQueueInstance* queue, F const action_queue::QueueSnapshot& before, const action_queue::QueueSnapshot& after, const char* source) { - if (!action_queue::IsNativePruneResumeCandidate(idle, before, after)) + if (ModReloading() || !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. @@ -247,6 +251,8 @@ void ActionQueueManager_OnFleetsDisposedEventHandler_Hook(auto original, ActionQ destroyed.push_back(id); original(_this, fleets); for (const auto target : destroyed) { + if (ModReloading()) + return; const auto queue = SnapshotOf(FindQueueWithHead(_this, target)); if (!action_queue::ShouldProcessDestroyedHead(target, queue)) continue; @@ -389,7 +395,7 @@ bool InstallQueueAddressGuard() // 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, - InstallActionQueueRecovery}; + InstallActionQueueRecovery, nullptr, ResetActionQueueRecovery}; const Feature kThinQueueProtection{"ThinQueueProtection", &Config::Patches::thinqueueprotectionhooks, InstallThinQueueProtection}; const Feature kQueueAddressGuard{"QueueAddressGuard", &Config::Patches::queueaddressguardhooks, diff --git a/src/mod/features/chat.cc b/src/mod/features/chat.cc index 0f6eb685e..aaad84290 100644 --- a/src/mod/features/chat.cc +++ b/src/mod/features/chat.cc @@ -32,6 +32,13 @@ il2cpp::GcHandle chat_preview; // Weak: the preview the hooks last saw. ChatStartup::Gate chat_startup; bool chat_width_applied = false; +void ResetChat() +{ + chat_preview.Reset(); + chat_startup = {}; + chat_width_applied = false; +} + void ObserveChatPreview(ChatPreviewController* preview) { if (!Config::Get().ui.auto_open_chat || chat_preview.Get() == preview) @@ -59,13 +66,13 @@ void ChatFrame() } if ((!ui.auto_open_chat || chat_startup.completed) && (!custom_width || chat_width_applied)) return; - auto* chat = ChatManager::Instance(); + auto* chat = ChatManager::Existing(); if (!chat) return; const bool side_open = chat->IsSideChatOpen(); // Once per login; after that, resizing is left to the player. if (side_open && custom_width && !chat_width_applied) { - auto* screens = ScreenManager::Instance(); + auto* screens = ScreenManager::Existing(); if (auto* frames = screens ? screens->frameManager() : nullptr) { frames->ResizeSideFrame(ui.default_chat_width); chat_width_applied = true; @@ -297,5 +304,5 @@ bool ChatChannelShortcuts() return false; } -const Feature kChat{"Chat", &Config::Patches::chatpatches, InstallChat, ChatFrame}; +const Feature kChat{"Chat", &Config::Patches::chatpatches, InstallChat, ChatFrame, ResetChat}; const Feature kChatTitles{"ChatTitles", &Config::Patches::chattitledisplayhooks, InstallChatTitles}; diff --git a/src/mod/features/dock.cc b/src/mod/features/dock.cc index 904033164..d6196c2ac 100644 --- a/src/mod/features/dock.cc +++ b/src/mod/features/dock.cc @@ -11,6 +11,7 @@ #include "config.h" #include "features/features.h" #include "file.h" +#include "mod.h" #include "text.h" #include "game/app.h" @@ -263,6 +264,14 @@ bool double_click_installed = false; // Compared only, never dereferenced. FleetPlayerData* last_clicked_ship = nullptr; std::chrono::steady_clock::time_point last_click_time{}; +int64_t dock_last_fleet_id = 0; + +void ResetDock() +{ + last_clicked_ship = nullptr; + last_click_time = {}; + dock_last_fleet_id = 0; +} void PressAssignButton() { @@ -308,6 +317,8 @@ void AssignOnEnterFrame() void ShipTileWidget_HandleOnClick_Hook(auto original, ShipTileWidget* _this) { original(_this); + if (ModReloading()) + return; // The click arrives through the UI event system, which can run before the frame tick has polled this frame. unity::Key::Poll(); if (auto* ship = _this->Context()) { @@ -328,7 +339,7 @@ FleetPlayerData* ManagedDockFleet() auto* controller = VisibleShipManagement(); auto* context = controller ? controller->Context() : nullptr; auto* fleet = context ? context->CurrentFleet() : nullptr; - auto* manager = FleetsManager::Instance(); + auto* manager = FleetsManager::Existing(); if (!fleet || !fleet->HasShip() || !manager || !manager->HasFleetService()) return nullptr; // Unassigned and synthetic fleets have no dock slot; the management list order is not the dock order. @@ -341,17 +352,16 @@ void DockSelectionFrame() { // Ten times a second; selecting a slot is not urgent. static Throttle throttle(std::chrono::milliseconds(100)); - static int64_t last_fleet_id = 0; if (!throttle.Due()) return; auto* fleet = ManagedDockFleet(); if (!fleet) { - last_fleet_id = 0; + dock_last_fleet_id = 0; return; } const auto id = fleet->Id(); auto* bar = unity::Newest(); - if (id == last_fleet_id || !bar) + if (id == dock_last_fleet_id || !bar) return; if (const auto index = fleet->Index(); !bar->IsIndexSelected(index)) { // The bar's own selection flow updates both the highlight and the fleet panel. @@ -361,7 +371,7 @@ void DockSelectionFrame() return; spdlog::debug("[DockSelection] selected dock slot {} for fleet {}", index + 1, id); } - last_fleet_id = id; + dock_last_fleet_id = id; } // --- Installation --- @@ -436,8 +446,8 @@ bool SelectManagedDock(int32_t index) const Feature kPinnedShips{"PinnedShips", &Config::Patches::pinnedshiphooks, InstallPinnedShips}; const Feature kPinnedShipClick{"PinnedShipClick", &Config::Patches::pinnedshipclicktoggle, InstallPinnedShipClick}; const Feature kDoubleClickAssign{"DoubleClickAssign", &Config::Patches::doubleclickassignshiphooks, - InstallDoubleClickAssign}; + InstallDoubleClickAssign, nullptr, ResetDock}; // Frame work only. Enter needs no hook, so it follows the setting alone. const Feature kAssignOnEnter{"AssignOnEnter", nullptr, nullptr, AssignOnEnterFrame}; // Frame work only, installed with the hotkeys it was introduced with. -const Feature kDockSelection{"DockSelection", &Config::Patches::hotkeyhooks, nullptr, DockSelectionFrame}; +const Feature kDockSelection{"DockSelection", &Config::Patches::hotkeyhooks, nullptr, DockSelectionFrame, ResetDock}; diff --git a/src/mod/features/features.h b/src/mod/features/features.h index 2f9b14927..af46ba677 100644 --- a/src/mod/features/features.h +++ b/src/mod/features/features.h @@ -24,6 +24,8 @@ struct Feature { // Runs every frame from the frame tick once installed, in every hotkey mode, before the shortcuts. Never runs for a // feature that failed to install. Returns at once when its feature is off. void (*frame)() = nullptr; + // Drops session-owned state before the game tears down its managers. Must not call into game objects. + void (*reset)() = nullptr; }; // Frame work that only needs to run every so often, such as a scan of the UI. Game thread only. @@ -108,6 +110,8 @@ void InstallFleetActions(); bool TargetViewerShown(); // The number keys. True when the key was used. bool SelectShipShortcuts(); +// Discards deferred fleet actions and selection double taps at the reload boundary. +void ResetFleetShortcuts(); // Select current and the queue switch. True when the key was used. bool FleetShortcuts(); // The actions on the selected fleet and the open viewer. True when the key must not reach the game. @@ -164,6 +168,7 @@ extern const Feature kOfficerSort; bool PressAwaitedButton(); // The shortcuts that open, step through, or search game screens. True when the key must not reach the game. bool ScreenShortcuts(); +void ResetScreenShortcuts(); // shop.cc extern const Feature kGiftsAutoOpen; diff --git a/src/mod/features/fleet_actions.cc b/src/mod/features/fleet_actions.cc index f8d8c84b4..9882dd478 100644 --- a/src/mod/features/fleet_actions.cc +++ b/src/mod/features/fleet_actions.cc @@ -47,7 +47,9 @@ struct FleetPress { // The target's hull type is only known once the target's data has arrived; until then it reads as Any. A press // that lands in that window is replayed, as itself, on the next frame only. -std::optional pending_replay; +std::optional pending_replay; +std::chrono::steady_clock::time_point fleet_last_press{}; +int32_t fleet_last_index = -1; bool ControllerShown(VisibilityController* controller) { return controller && controller->IsShown(); } @@ -408,7 +410,7 @@ void AbilityButtonsFrame() auto* fleet = controller ? controller->fleet() : nullptr; if (!fleet || (std::to_underlying(fleet->CurrentState()) & std::to_underlying(FleetState::CanActivateAbility)) == 0) return; - auto* manager = ActivatedAbilityManager::Instance(); + auto* manager = ActivatedAbilityManager::Existing(); auto* panel = controller->shipActionPanelLocalViewController(); if (!manager || !panel || !CanvasController::Shows(panel) || panel->HasShipAnActiveAbilityWithActiveEffect()) return; @@ -437,6 +439,13 @@ bool InstallQueueSwitch() bool TargetViewerShown() { return TargetViewers::AnyShown(); } +void ResetFleetShortcuts() +{ + pending_replay.reset(); + fleet_last_press = {}; + fleet_last_index = -1; +} + bool SelectShipShortcuts() { using Clock = std::chrono::steady_clock; @@ -444,8 +453,6 @@ bool SelectShipShortcuts() // camera to it. With disable_preview_locate, the second press only selects while a target card is open. constexpr std::array kShips{Action::kSelectShip1, Action::kSelectShip2, Action::kSelectShip3, Action::kSelectShip4, Action::kSelectShip5, Action::kSelectShip6, Action::kSelectShip7, Action::kSelectShip8}; - static auto last_press = Clock::now(); - static int32_t last_index = -1; const auto ship = std::ranges::find_if(kShips, [](Action action) { return Shortcut::IsDown(action); }); // TODO(tashcan): Shift+ship used to request a tow by the Discovery. Its bindings had drifted, so it never ran on @@ -465,8 +472,8 @@ bool SelectShipShortcuts() const auto now = Clock::now(); const bool can_locate = !Config::Get().ui.disable_preview_locate || !TargetViewerShown(); - if (can_locate && index == last_index && fleet_bar->IsIndexSelected(index) - && now - last_press < std::chrono::milliseconds(Config::Get().control.select_timer)) { + if (can_locate && index == fleet_last_index && fleet_bar->IsIndexSelected(index) + && now - fleet_last_press < std::chrono::milliseconds(Config::Get().control.select_timer)) { auto* fleet = fleet_bar->SelectedFleet(); if (!fleet) return true; @@ -474,8 +481,8 @@ bool SelectShipShortcuts() } else { fleet_bar->RequestSelect(index); } - last_index = index; - last_press = now; + fleet_last_index = index; + fleet_last_press = now; return true; } diff --git a/src/mod/features/fleet_labels.cc b/src/mod/features/fleet_labels.cc index 24b93d191..f521013be 100644 --- a/src/mod/features/fleet_labels.cc +++ b/src/mod/features/fleet_labels.cc @@ -176,6 +176,9 @@ class FleetLabels Camera camera_; } fleet_labels; +void ResetFleetLabels() +{ fleet_labels = {}; } + // --- Hooks --- void NavigationLOD_UpdateLOD_Hook(auto original, NavigationLOD* _this, ZoomLevels level) @@ -236,4 +239,4 @@ void FleetLabelsFollowDepth(NavigationZoom* zoom, NodeDepth depth) void FleetLabelsAfterZoom(NavigationZoom* zoom) { fleet_labels.OnZoomed(zoom); } -const Feature kFleetLabels{"FleetLabels", &Config::Patches::zoomhooks, InstallFleetLabels}; +const Feature kFleetLabels{"FleetLabels", &Config::Patches::zoomhooks, InstallFleetLabels, nullptr, ResetFleetLabels}; diff --git a/src/mod/features/hotkeys.cc b/src/mod/features/hotkeys.cc index 699930d6b..ec68987b3 100644 --- a/src/mod/features/hotkeys.cc +++ b/src/mod/features/hotkeys.cc @@ -23,7 +23,11 @@ namespace { // The frame tick dispatches shortcuts whether or not this feature installed; this says whether it did. -bool hotkeys_installed = false; +bool hotkeys_installed = false; +std::optional hotkeys_escape_first_press; + +void ResetHotkeys() +{ hotkeys_escape_first_press.reset(); } // --- Setting shortcuts --- @@ -94,14 +98,14 @@ bool SwallowEscape() if (config.ui.disable_escape_exit_timer <= 0 || !unity::Key::Down(unity::KeyCode::Escape)) return true; - static std::optional first_press; - const auto now = std::chrono::steady_clock::now(); - if (!first_press || now - *first_press > std::chrono::milliseconds(config.ui.disable_escape_exit_timer)) { - first_press = now; + const auto now = std::chrono::steady_clock::now(); + if (!hotkeys_escape_first_press + || now - *hotkeys_escape_first_press > std::chrono::milliseconds(config.ui.disable_escape_exit_timer)) { + hotkeys_escape_first_press = now; return true; } // Consume the pair so a quick third press starts a new one. - first_press.reset(); + hotkeys_escape_first_press.reset(); return false; } @@ -204,4 +208,4 @@ bool HandleShortcuts() return FleetActionShortcuts() || SwallowEscape(); } -const Feature kHotkeys{"Hotkeys", &Config::Patches::hotkeyhooks, InstallHotkeys}; +const Feature kHotkeys{"Hotkeys", &Config::Patches::hotkeyhooks, InstallHotkeys, nullptr, ResetHotkeys}; diff --git a/src/mod/features/instant_warp.cc b/src/mod/features/instant_warp.cc index 438f67f13..9157d5301 100644 --- a/src/mod/features/instant_warp.cc +++ b/src/mod/features/instant_warp.cc @@ -3,6 +3,7 @@ #include "config.h" #include "features/features.h" +#include "mod.h" #include "text.h" #include "game/fleet.h" @@ -50,6 +51,8 @@ InstantWarpConfirmation ResolveConfirmation(FleetPlayerData* fleet) void CoursePromptPopupViewController_AboutToShow_Hook(auto original, CoursePromptPopupViewController* _this) { original(_this); + if (ModReloading()) + return; auto* widget = _this->coursePromptPopupWidget(); auto* context = widget ? widget->Context() : nullptr; if (!context || !context->HasInstantWarp()) @@ -123,6 +126,13 @@ struct OwnedLabel { // Never destroyed: freeing a GC handle in a static destructor can run after the runtime has shut down. auto& action_label = *new OwnedLabel; +void ResetInstantWarp() +{ + // Forget ownership without touching a label whose hierarchy is being destroyed. + action_label.handle.Reset(); + action_label.text.clear(); +} + void InstantWarpFrame() { // Ten times a second is quick enough for a label. @@ -145,8 +155,8 @@ void InstantWarpFrame() // The ordinary visit path uses the currently selected fleet. The card's cached popup CourseData is only filled on // click, so it cannot preview. Toll and wormhole buttons confirm their own way and keep the game's text. if (unity::Alive(button) && button->isActiveAndEnabled() && button->Interactable()) { - auto* fleets = FleetsManager::Instance(); - auto* deployment = DeploymentManager::Instance(); + auto* fleets = FleetsManager::Existing(); + auto* deployment = DeploymentManager::Existing(); auto* fleet = unity::Alive(fleets) ? fleets->GetSelectedFleetData() : nullptr; if (fleet && unity::Alive(deployment) && deployment->HasInstantWarpAbility(fleet)) { switch (ResolveConfirmation(fleet)) { @@ -192,4 +202,4 @@ void CycleInstantWarpConfirmation() } // Always installed: its only hook answers a prompt the player asked the mod to answer. -const Feature kInstantWarp{"InstantWarp", nullptr, InstallInstantWarp, InstantWarpFrame}; +const Feature kInstantWarp{"InstantWarp", nullptr, InstallInstantWarp, InstantWarpFrame, ResetInstantWarp}; diff --git a/src/mod/features/loading_screens.cc b/src/mod/features/loading_screens.cc index d53fad95c..9d76f6b7b 100644 --- a/src/mod/features/loading_screens.cc +++ b/src/mod/features/loading_screens.cc @@ -387,27 +387,14 @@ void SlideShowViewController_ShowCurrentSlide_Hook(auto original, SlideShowViewC } // The hierarchy holding our overlays is destroyed during a reload; forget it so Awake does not touch dead objects. -void MonoSingleton_PrepareAllForReload_Hook(auto original) +void ResetLoadingScreens() { transition_screen = {}; tip_picker.Reset(); - original(); -} - -// Shared by the login, tip and transition installs, whichever of them run, so it is its own set, installed on the -// first call. -void InstallReloadReset() -{ - [[maybe_unused]] static const bool installed = [] { - il2cpp::HookSet hooks("LoadingScreens"); - IL2CPP_STAGE(hooks, MonoSingletonBase::Method("PrepareAllForReload"), MonoSingleton_PrepareAllForReload_Hook); - return hooks.Commit(); - }(); } bool InstallLoginScreen() { - InstallReloadReset(); il2cpp::HookSet hooks("LoginScreen"); IL2CPP_STAGE(hooks, LoginSequence::Class::Method("Awake"), LoginSequence_Awake_Hook); return hooks.Commit(); @@ -415,7 +402,6 @@ bool InstallLoginScreen() bool InstallTransitionScreen() { - InstallReloadReset(); il2cpp::HookSet hooks("TransitionScreen"); IL2CPP_STAGE(hooks, TransitionViewController::Class::Method("Awake"), TransitionViewController_Awake_Hook); IL2CPP_STAGE(hooks, TransitionViewController::Class::Method("AboutToShow"), @@ -429,7 +415,6 @@ bool InstallTransitionScreen() bool InstallLoadingTips() { - InstallReloadReset(); il2cpp::HookSet hooks("LoadingTips"); IL2CPP_STAGE(hooks, LoadingTipViewController::Class::Method("SetRandomTipLocalisedText"), LoadingTipViewController_SetRandomTipLocalisedText_Hook); @@ -437,6 +422,9 @@ bool InstallLoadingTips() } } // namespace -const Feature kLoginScreen{"LoginScreen", &Config::Patches::loadingscreenhooks, InstallLoginScreen}; -const Feature kTransitionScreen{"TransitionScreen", &Config::Patches::transitionscreenhooks, InstallTransitionScreen}; -const Feature kLoadingTips{"LoadingTips", &Config::Patches::loadingtiphooks, InstallLoadingTips}; +const Feature kLoginScreen{"LoginScreen", &Config::Patches::loadingscreenhooks, InstallLoginScreen, nullptr, + ResetLoadingScreens}; +const Feature kTransitionScreen{"TransitionScreen", &Config::Patches::transitionscreenhooks, InstallTransitionScreen, + nullptr, ResetLoadingScreens}; +const Feature kLoadingTips{"LoadingTips", &Config::Patches::loadingtiphooks, InstallLoadingTips, nullptr, + ResetLoadingScreens}; diff --git a/src/mod/features/screens.cc b/src/mod/features/screens.cc index b828fe815..935f87e02 100644 --- a/src/mod/features/screens.cc +++ b/src/mod/features/screens.cc @@ -337,6 +337,9 @@ bool PressAwaitedButton() return false; } +void ResetScreenShortcuts() +{ pending_button.reset(); } + bool ScreenShortcuts() { if (Shortcut::IsDown(Action::kMoveLeft) && StepScreen(-1)) diff --git a/src/mod/game/app.h b/src/mod/game/app.h index fbc36495f..edd24eabe 100644 --- a/src/mod/game/app.h +++ b/src/mod/game/app.h @@ -162,6 +162,8 @@ struct SectionManager : il2cpp::Object { SectionID CurrentSection() { return Get<"CurrentSection", SectionID>(); } + bool IsSectionChangeInProgress() + { return Get<"IsSectionChangeInProgress", bool>(); } SectionStorage* sectionStorage() { return Field<"_sectionStorage", SectionStorage*>(); } @@ -191,6 +193,12 @@ struct CustomReloadArgs { bool force_fake_reload; }; +struct AppReloadArgs { + bool switch_to_login_screen; + bool quick_reloading; + il2cpp::Nullable custom_reload_args; +}; + struct PrimeApp : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.Core", "PrimeApp">; diff --git a/src/mod/mod.cc b/src/mod/mod.cc index bbfb59128..e6850d5ac 100644 --- a/src/mod/mod.cc +++ b/src/mod/mod.cc @@ -6,6 +6,7 @@ #include "config.h" #include "features/features.h" #include "file.h" +#include "reload.h" #include "version.h" #include "game/ui.h" @@ -17,6 +18,7 @@ #include #include +#include #include #include #include @@ -183,7 +185,14 @@ constexpr const Feature* kFeatures[] = { }; // The installed features with frame work, in install order. -std::vector frame_features; +std::vector frame_features; +std::vector reset_features; +reload::Gate reload_gate; +uint32_t reload_call_depth = 0; +uint64_t reload_generation = 0; +std::chrono::steady_clock::time_point reload_started; +bool reload_wait_reported = false; +bool reload_navigation_pending = false; // Runs one piece of frame work for `name`. An exception must not unwind into the game's native frame. template void RunGuarded(const char* name, Work&& work) @@ -197,15 +206,92 @@ template void RunGuarded(const char* name, Work&& work) } } +void BeginReload(const char* source) +{ + const bool first = !reload_gate.suspended; + reload_gate.Begin(); + if (first) { + ++reload_generation; + reload_started = std::chrono::steady_clock::now(); + reload_wait_reported = false; + reload_navigation_pending = true; + spdlog::info("[Reload] session {} starting at {}; suspending mod frame work", reload_generation, source); + } + // DoReload and PrepareAllForReload may both run. Forget anything observed between the two teardown stages too. + for (const auto* feature : reset_features) + RunGuarded(feature->name, feature->reset); + // These helpers have no feature descriptor; pending UI requests must never cross a reload. + ResetScreenShortcuts(); + ResetFleetShortcuts(); +} + +void PrimeApp_DoReload_Hook(auto original, PrimeApp* _this, AppReloadArgs args) +{ + BeginReload("PrimeApp.DoReload"); + ++reload_call_depth; + original(_this, args); + --reload_call_depth; +} + +void MonoSingleton_PrepareAllForReload_Hook(auto original) +{ + BeginReload("MonoSingleton.PrepareAllForReload"); + ++reload_call_depth; + original(); + --reload_call_depth; +} + +bool ResumeAfterReload(ScreenManager* screen) +{ + // Hub and Existing only read existing objects. Instance getters can recreate managers during teardown. + auto* sections = Hub::Sections(); + const auto section = sections ? sections->CurrentSection() : SectionID::AppInit; + const bool ready = reload_call_depth == 0 && sections && ScreenManager::Existing() == screen + && section != SectionID::AppInit && section != SectionID::Login && section != SectionID::Reentry + && section != SectionID::IntroVideo && !sections->IsSectionChangeInProgress(); + const auto now = std::chrono::steady_clock::now(); + if (reload_gate.Tick(ready, std::chrono::duration(now.time_since_epoch()).count())) { + spdlog::info("[Reload] session {} ready after {} ms in section {}; resuming mod frame work", reload_generation, + std::chrono::duration_cast(now - reload_started).count(), + static_cast(section)); + return true; + } + if (!reload_wait_reported && now - reload_started >= std::chrono::seconds(60)) { + reload_wait_reported = true; + spdlog::warn("[Reload] session {} still waiting for readiness in section {}; game updates remain active", + reload_generation, static_cast(section)); + } + return false; +} + // ScreenManager.Update is the mod's frame tick and runs in every hotkey mode: input, the settings file watch and // each installed feature's frame work, then the shortcut dispatch. Skipping the game's Update swallows a key a // shortcut used, so the game does not handle it as well. void ScreenManager_Update_Hook(auto original, ScreenManager* _this) { unity::Key::Poll(); + if (reload_gate.suspended) { + // The game must keep ticking while the mod is suspended, including the frame that completes readiness. + original(_this); + ResumeAfterReload(_this); + return; + } + if (reload_navigation_pending) { + const auto section = Hub::CurrentSection(); + if (section == SectionID::Navigation_System || section == SectionID::Navigation_Galaxy) { + reload_navigation_pending = false; + spdlog::info("[Reload] session {} reached navigation section {}", reload_generation, + static_cast(section)); + } + } RunGuarded("Settings", Config::ReloadIfChanged); - for (const auto* feature : frame_features) + for (const auto* feature : frame_features) { RunGuarded(feature->name, feature->frame); + if (reload_gate.suspended) { + original(_this); + return; + } + } bool handled = false; RunGuarded("Shortcuts", [&] { handled = HandleShortcuts(); }); if (!handled) @@ -237,11 +323,16 @@ void InstallFeatures(const Config& config) spdlog::info(" + Patching {:>2} of {} ({})", i + 1, total, feature.name); if (feature.frame) frame_features.push_back(&feature); + if (feature.reset) + reset_features.push_back(&feature); } // Last, so a feature never sees a frame before it is installed. Without the tick nothing polls, and the settings // file is only read at startup. il2cpp::HookSet hooks("FrameTick"); IL2CPP_STAGE(hooks, ScreenManager::Class::Method("Update"), ScreenManager_Update_Hook); + IL2CPP_STAGE(hooks, PrimeApp::Class::Method("DoReload"), PrimeApp_DoReload_Hook); + IL2CPP_STAGE(hooks, MonoSingletonBase::StaticMethod("PrepareAllForReload"), + MonoSingleton_PrepareAllForReload_Hook); if (!hooks.Commit()) spdlog::error("the frame tick is not installed: shortcuts, settings reload and frame updates will not run"); } @@ -292,6 +383,9 @@ int il2cpp_init_Hook(auto original, const char* domain_name) } } // namespace +bool ModReloading() +{ return reload_gate.suspended; } + void StartMod() { #if _WIN32 diff --git a/src/mod/mod.h b/src/mod/mod.h index b90eecaf5..f3952e252 100644 --- a/src/mod/mod.h +++ b/src/mod/mod.h @@ -5,6 +5,9 @@ // Loads GameAssembly and hooks il2cpp_init, which installs everything else once the runtime is up. void StartMod(); +// Game thread only. UI automation must leave the game's own path alone while an in-process reload settles. +bool ModReloading(); + // Reports a failure from before the mod's log exists. Each platform entry point defines it where a user can find // the message: debugger output on Windows, the system log on macOS. void ReportStartupFailure(const char* message); diff --git a/tests/mod_logic.cc b/tests/mod_logic.cc index f63d93826..22debea73 100644 --- a/tests/mod_logic.cc +++ b/tests/mod_logic.cc @@ -4,6 +4,7 @@ #include "features/notification_audio.h" #include "features/ship_shortcut_hint.h" #include "features/toast_text.h" +#include "reload.h" #include "shortcuts.h" #include "text.h" @@ -534,6 +535,23 @@ void ChatStartupGate() "chat already open consumes the gate, even before readiness"); } +void ReloadGate() +{ + reload::Gate gate; + Check(!gate.suspended && !gate.Tick(false, 0), "normal startup is not suspended"); + gate.Begin(); + Check(gate.suspended && !gate.Tick(false, 10), "reload stays suspended without managers"); + Check(!gate.Tick(true, 20) && !gate.Tick(true, 22.9), "readiness must settle before mod work resumes"); + Check(!gate.Tick(false, 23) && !gate.Tick(true, 24), "a transition restarts the settling period"); + Check(!gate.Tick(true, 26.9) && gate.Tick(true, 27) && !gate.suspended, "stable readiness resumes work"); + Check(!gate.Tick(true, 30), "completion is emitted only once"); + gate.Begin(); + Check(!gate.Tick(true, 40), "a later reload suspends again"); + gate.Begin(); + Check(!gate.Tick(true, 43) && !gate.Tick(true, 45.9) && gate.Tick(true, 46), + "nested teardown notifications restart the settling period"); +} + int main() { ShipHints(); @@ -553,6 +571,7 @@ int main() ParseDoesNotActivateSettings(); Matching(); ChatStartupGate(); + ReloadGate(); if (failures) return EXIT_FAILURE; std::cout << "mod logic tests passed\n";