Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 10 additions & 4 deletions src/mod/features/action_queue.cc
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include "features/action_queue.h"
#include "config.h"
#include "features/features.h"
#include "mod.h"

#include "game/queues.h"

Expand All @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 10 additions & 3 deletions src/mod/features/chat.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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};
24 changes: 17 additions & 7 deletions src/mod/features/dock.cc
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#include "config.h"
#include "features/features.h"
#include "file.h"
#include "mod.h"
#include "text.h"

#include "game/app.h"
Expand Down Expand Up @@ -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()
{
Expand Down Expand Up @@ -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()) {
Expand All @@ -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.
Expand All @@ -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<FleetBarViewController>();
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.
Expand All @@ -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 ---
Expand Down Expand Up @@ -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};
5 changes: 5 additions & 0 deletions src/mod/features/features.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
23 changes: 15 additions & 8 deletions src/mod/features/fleet_actions.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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<FleetPress> pending_replay;
std::optional<FleetPress> 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(); }
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -437,15 +439,20 @@ 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;
// The number keys select a ship in the fleet bar; pressing the same one again within select_timer flies the
// 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
Expand All @@ -465,17 +472,17 @@ 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;
ViewSelectedFleet(fleet);
} else {
fleet_bar->RequestSelect(index);
}
last_index = index;
last_press = now;
fleet_last_index = index;
fleet_last_press = now;
return true;
}

Expand Down
5 changes: 4 additions & 1 deletion src/mod/features/fleet_labels.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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};
18 changes: 11 additions & 7 deletions src/mod/features/hotkeys.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::chrono::steady_clock::time_point> hotkeys_escape_first_press;

void ResetHotkeys()
{ hotkeys_escape_first_press.reset(); }

// --- Setting shortcuts ---

Expand Down Expand Up @@ -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<std::chrono::steady_clock::time_point> 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;
}

Expand Down Expand Up @@ -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};
16 changes: 13 additions & 3 deletions src/mod/features/instant_warp.cc
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

#include "config.h"
#include "features/features.h"
#include "mod.h"
#include "text.h"

#include "game/fleet.h"
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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.
Expand All @@ -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)) {
Expand Down Expand Up @@ -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};
Loading
Loading