From c97f85950d39501fbb8fbf4391b1f635622aff36 Mon Sep 17 00:00:00 2001 From: Igor Kolchinskii <33728971+leosnake2208@users.noreply.github.com> Date: Sun, 19 Jul 2026 01:43:45 +0200 Subject: [PATCH 1/5] Add modern control scheme (right-click to command) Optional control scheme matching modern RTS games: the right mouse button issues orders to the current selection, while the left mouse button only selects/deploys and deselects on empty ground. Reuses the game's own command dispatch (and network path) by redirecting the RMB-up handler into the shared LMB-up command dispatch. Special left-click modes (repair/sell/place/beacon/ superweapon/planning) keep their vanilla behaviour. Gated behind [Phobos] ModernControls (default off). --- CREDITS.md | 2 + Phobos.vcxproj | 1 + docs/User-Interface.md | 16 ++++ docs/Whats-New.md | 2 + src/Misc/ModernControls.cpp | 151 ++++++++++++++++++++++++++++++++++++ src/Phobos.INI.cpp | 2 + src/Phobos.h | 1 + 7 files changed, 175 insertions(+) create mode 100644 src/Misc/ModernControls.cpp diff --git a/CREDITS.md b/CREDITS.md index 731ccc454c..3b4e3a74f4 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -888,3 +888,5 @@ This page lists all the individual contributions to the project by their author. - **Chang_zhi**: - Interop export interface for accessing scenario local/global variables - Add `ClampToScreen` tag for `BannerType` to control whether banner position is clamped to the visible area +- **Igor Kolchinskii (leosnake2208)**: + - Modern control scheme (right-click to command) diff --git a/Phobos.vcxproj b/Phobos.vcxproj index cea5e31f02..f2179f303a 100644 --- a/Phobos.vcxproj +++ b/Phobos.vcxproj @@ -263,6 +263,7 @@ + diff --git a/docs/User-Interface.md b/docs/User-Interface.md index 26fc2b8418..67ab642e53 100644 --- a/docs/User-Interface.md +++ b/docs/User-Interface.md @@ -262,6 +262,22 @@ In `RA2MD.INI`: PrioritySelectionFiltering=true ; boolean ``` +### Modern control scheme + +- An optional control scheme matching modern RTS games: the **right mouse button** issues orders to the current selection (move, attack, enter, capture, etc.), while the **left mouse button** only selects, box-selects and self-deploys, and deselects when clicking empty ground. It reuses the game's own command dispatch, so all order and network logic is unchanged. + - Special left-click modes are preserved on both buttons: while placing a building, repairing, selling, toggling power, planting a beacon, planning a path or targeting a superweapon, the left button performs that action and the right button cancels it, exactly as in vanilla. +- Enable it with `ModernControls=true`. + +```{note} +This only changes mouse behaviour; keyboard hotkeys are unaffected. It does not rebind any keys. +``` + +In `RA2MD.INI`: +```ini +[Phobos] +ModernControls=false ; boolean +``` + ### Placement preview ![placepreview](_static/images/placepreview.png) diff --git a/docs/Whats-New.md b/docs/Whats-New.md index 1befcdb9ae..505261d045 100644 --- a/docs/Whats-New.md +++ b/docs/Whats-New.md @@ -140,6 +140,7 @@ ShowBriefing=true ; boolean DigitalDisplay.Enable=false ; boolean ShowDesignatorRange=false ; boolean PrioritySelectionFiltering=true ; boolean +ModernControls=false ; boolean PriorityDeployFiltering=true ; boolean ShowPlacementPreview=yes ; boolean RealTimeTimers=false ; boolean @@ -386,6 +387,7 @@ HideShakeEffects=false ; boolean :open: #### New: +- [Modern control scheme (right-click to command)](User-Interface.md#modern-control-scheme) (by leosnake2208) - [Allow using waypoints, area guard and attack move with aircraft](Fixed-or-Improved-Logics.md#extended-aircraft-missions) (by CrimRecya) - [Enhanced Straight trajectory](New-or-Enhanced-Logics.md#straight-trajectory) (by CrimRecya) - [Enable building production queue](User-Interface.md#building-production-queue) (by CrimRecya) diff --git a/src/Misc/ModernControls.cpp b/src/Misc/ModernControls.cpp new file mode 100644 index 0000000000..ef43427e8c --- /dev/null +++ b/src/Misc/ModernControls.cpp @@ -0,0 +1,151 @@ +#include + +#include +#include +#include +#include +#include // pulls in the complete FootClass/TechnoClass definitions that + // MapClass/DisplayClass inline abstract_casts require + +// Modern control scheme: the right mouse button issues orders, the left mouse button only +// selects / deploys (and deselects when clicking empty ground), matching the control style +// of modern RTS games. Off by default; enable with [Phobos] -> ModernControls. +// +// The tactical mouse message handler is MouseClass method 0x6930A0, which dispatches Windows +// mouse messages through a jump table (0x693410). Relevant cases (reverse-engineered): +// LBUTTONDOWN 0x201 -> 0x693126 (begin select/action; sets the drag flag [this+0x555A]=1) +// LBUTTONUP 0x202 -> 0x6931F9 (command dispatch: ProcessClickCoords -> DecideAction +// -> apply 0x4AB9B0, from 0x69323E onward) +// RBUTTONUP 0x205 -> 0x693366 (vanilla: cancel current mode, then deselect) +// +// RBUTTONUP and LBUTTONUP are two cases of the SAME function, sharing one stack frame and +// the same `this`; both prologues turn [esp+0x34] from a message-point pointer into an inline +// Point2D. So the RMB-up handler can jump straight into the LMB-up command dispatch at +// 0x69323E, reusing 100% of the game's command (and network) logic - no argument +// reconstruction is needed. + +namespace ModernControls +{ + // A special LEFT-click mode is active - RMB must keep its vanilla "cancel" behaviour + // (cancel building placement, leave repair/sell/power/beacon/superweapon targeting) and + // LMB must keep its vanilla behaviour (repair/sell/place/target). + static bool InSpecialLeftClickMode() + { + auto& d = DisplayClass::Instance; + return d.RepairMode + || d.SellMode + || d.PowerToggleMode + || d.PlaceBeaconMode + || d.PlanningMode + || d.CurrentSWTypeIndex >= 0 // superweapon targeting + || d.CurrentBuilding != nullptr; // building placement + } + + // Set by the RBUTTONUP command hook right before it jumps into the shared LMB-up command + // dispatch (0x69323E), which flows through the LBUTTONUP neutralise hook at 0x693276. + // Without this flag that hook would downgrade the RMB-issued order to None; the LBUTTONUP + // hook consumes (clears) it. + static bool RmbCommandInProgress = false; + + // Actions the LEFT button may still perform: selection and self-deploy only. Everything + // else (Move/Attack/Enter/Harvest/Capture/Guard/...) is a command and belongs to the + // RIGHT button now. + static bool IsLeftClickAllowed(Action action) + { + switch (action) + { + case Action::None: + case Action::Select: + case Action::ToggleSelect: + case Action::Self_Deploy: + return true; + default: + return false; + } + } + + // If modern controls are on and no special left-click mode is active, downgrade the + // command action (in EAX, freshly returned by DecideAction) to None so the left button + // only selects/deploys. Returns true if a command was actually neutralised (the click + // landed on empty ground / an enemy - a command target, not a selectable own unit). + static bool NeutraliseLeftCommand(REGISTERS* R) + { + if (Phobos::Config::ModernControls && !InSpecialLeftClickMode()) + { + if (!IsLeftClickAllowed(static_cast(R->EAX()))) + { + R->EAX(static_cast(Action::None)); + return true; + } + } + return false; + } +} + +// RBUTTONUP handler, just past its "press/drag in progress" gate (cmp [this+0x555A],bl / +// je 0x693408 at 0x69338F), so it inherits the same gate the vanilla deselect uses. Stolen +// bytes: cmp byte ptr [0x884D40], bl (absolute operand, safe to relocate). +// +// The LMB-up dispatch we jump into (0x69323E) reads the click point as &[esp+0x10], i.e. the +// view-relative coords (raw window xy minus the tactical view origin at 0x886FA0/0x886FA4). +// The LMB prologue stores those at [esp+0x10]/[esp+0x14]; the RMB prologue computes the same +// values but only passes them to 0x63AB00 without storing them, so we populate the slots +// ourselves before jumping (otherwise ProcessClickCoords reads stale stack and the order +// lands on the wrong cell). +DEFINE_HOOK(0x693397, TacticalMsgHandler_RButtonUp_ModernCommand, 0x6) +{ + if (Phobos::Config::ModernControls + && ObjectClass::CurrentObjects.Count > 0 + && !ModernControls::InSpecialLeftClickMode()) + { + // Raw packed window xy stashed by the RMB prologue at [esp+0x34]. + const int packed = R->Stack(0x34); + const int rawX = static_cast(packed & 0xFFFF); + const int rawY = static_cast((packed >> 16) & 0xFFFF); + + const int originX = *reinterpret_cast(0x886FA0); + const int originY = *reinterpret_cast(0x886FA4); + + // Feed the LMB-up command dispatch the view-relative click point. + R->Stack(0x10, rawX - originX); + R->Stack(0x14, rawY - originY); + + // Tell the LBUTTONUP neutralise hook to leave this (RMB-issued) command alone. + ModernControls::RmbCommandInProgress = true; + + // Issue the order to the current selection instead of deselecting. + return 0x69323E; + } + + // Vanilla: run the stolen compare and fall through to the cancel/deselect path. + return 0; +} + +// LBUTTONDOWN: neutralise a command action right after DecideAction so button-down does not +// preview/issue a command. Stolen bytes: mov reg,[esp+..] + push eax (the possibly-modified +// EAX is what the following push forwards to the applier). +DEFINE_HOOK(0x6931B4, TacticalMsgHandler_LButtonDown_ModernSelectOnly, 0x5) +{ + ModernControls::NeutraliseLeftCommand(R); + return 0; +} + +// LBUTTONUP: same neutralise, and turn a would-be command on empty ground / an enemy into a +// deselect (MapClass::UnselectAll). Clicking own units (Select/ToggleSelect) or deploying +// (Self_Deploy) is not neutralised, so it selects/deploys as normal - no deselect there. +// Completed band-selects never reach here (0x63A8E0 consumes them and early-exits at +// 0x693408), so deselecting on a neutralised command is always correct. +DEFINE_HOOK(0x693276, TacticalMsgHandler_LButtonUp_ModernSelectOnly, 0x5) +{ + // If we arrived here via the RMB command redirect (0x69323E), let the order stand. + if (ModernControls::RmbCommandInProgress) + { + ModernControls::RmbCommandInProgress = false; + return 0; + } + + if (ModernControls::NeutraliseLeftCommand(R)) + MapClass::UnselectAll(); + + return 0; +} diff --git a/src/Phobos.INI.cpp b/src/Phobos.INI.cpp index ac9f7649b2..9582205194 100644 --- a/src/Phobos.INI.cpp +++ b/src/Phobos.INI.cpp @@ -49,6 +49,7 @@ bool Phobos::Config::ToolTipDescriptions = true; bool Phobos::Config::ToolTipBlur = false; bool Phobos::Config::PrioritySelectionFiltering = true; bool Phobos::Config::PriorityDeployFiltering = true; +bool Phobos::Config::ModernControls = false; bool Phobos::Config::TypeSelectUseIFVMode = true; bool Phobos::Config::DevelopmentCommands = true; bool Phobos::Config::SuperWeaponSidebarCommands = false; @@ -94,6 +95,7 @@ DEFINE_HOOK(0x5FACDF, OptionsClass_LoadSettings_LoadPhobosSettings, 0x5) Phobos::Config::ToolTipBlur = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "ToolTipBlur", false); Phobos::Config::PrioritySelectionFiltering = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "PrioritySelectionFiltering", true); Phobos::Config::PriorityDeployFiltering = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "PriorityDeployFiltering", true); + Phobos::Config::ModernControls = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "ModernControls", false); Phobos::Config::TypeSelectUseIFVMode = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "TypeSelectUseIFVMode", true); Phobos::Config::ShowPlacementPreview = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "ShowPlacementPreview", true); Phobos::Config::MessageApplyHoverState = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "MessageApplyHoverState", false); diff --git a/src/Phobos.h b/src/Phobos.h index 6bdb4073ec..75b171be39 100644 --- a/src/Phobos.h +++ b/src/Phobos.h @@ -84,6 +84,7 @@ class Phobos static bool ToolTipBlur; static bool PrioritySelectionFiltering; static bool PriorityDeployFiltering; + static bool ModernControls; static bool TypeSelectUseIFVMode; static bool DevelopmentCommands; static bool SuperWeaponSidebarCommands; From f0bf76304a18a51279f774df74718aa3ad565038 Mon Sep 17 00:00:00 2001 From: Igor Kolchinskii <33728971+leosnake2208@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:08:22 +0200 Subject: [PATCH 2/5] Rename ModernControls to RightClickCommand Address review feedback (Coronia): "ModernControls" is an ambiguous name. Rename the INI tag, config field, source file, namespace and hook names to the clearer "RightClickCommand"; update docs and CREDITS. Co-Authored-By: Claude Opus 4.8 --- CREDITS.md | 2 +- Phobos.vcxproj | 2 +- docs/User-Interface.md | 32 +++++++++---------- docs/Whats-New.md | 4 +-- ...dernControls.cpp => RightClickCommand.cpp} | 30 ++++++++--------- src/Phobos.INI.cpp | 4 +-- src/Phobos.h | 2 +- 7 files changed, 38 insertions(+), 38 deletions(-) rename src/Misc/{ModernControls.cpp => RightClickCommand.cpp} (86%) diff --git a/CREDITS.md b/CREDITS.md index 3b4e3a74f4..90d1db280d 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -889,4 +889,4 @@ This page lists all the individual contributions to the project by their author. - Interop export interface for accessing scenario local/global variables - Add `ClampToScreen` tag for `BannerType` to control whether banner position is clamped to the visible area - **Igor Kolchinskii (leosnake2208)**: - - Modern control scheme (right-click to command) + - Right-click to command diff --git a/Phobos.vcxproj b/Phobos.vcxproj index f2179f303a..9b93cd331e 100644 --- a/Phobos.vcxproj +++ b/Phobos.vcxproj @@ -263,9 +263,9 @@ - + diff --git a/docs/User-Interface.md b/docs/User-Interface.md index 67ab642e53..010b44ed49 100644 --- a/docs/User-Interface.md +++ b/docs/User-Interface.md @@ -262,22 +262,6 @@ In `RA2MD.INI`: PrioritySelectionFiltering=true ; boolean ``` -### Modern control scheme - -- An optional control scheme matching modern RTS games: the **right mouse button** issues orders to the current selection (move, attack, enter, capture, etc.), while the **left mouse button** only selects, box-selects and self-deploys, and deselects when clicking empty ground. It reuses the game's own command dispatch, so all order and network logic is unchanged. - - Special left-click modes are preserved on both buttons: while placing a building, repairing, selling, toggling power, planting a beacon, planning a path or targeting a superweapon, the left button performs that action and the right button cancels it, exactly as in vanilla. -- Enable it with `ModernControls=true`. - -```{note} -This only changes mouse behaviour; keyboard hotkeys are unaffected. It does not rebind any keys. -``` - -In `RA2MD.INI`: -```ini -[Phobos] -ModernControls=false ; boolean -``` - ### Placement preview ![placepreview](_static/images/placepreview.png) @@ -335,6 +319,22 @@ RealTimeTimers=false ; boolean RealTimeTimers.Adaptive=false ; boolean ``` +### Right-click to command + +- An optional control scheme matching modern RTS games: the **right mouse button** issues orders to the current selection (move, attack, enter, capture, etc.), while the **left mouse button** only selects, box-selects and self-deploys, and deselects when clicking empty ground. It reuses the game's own command dispatch, so all order and network logic is unchanged. + - Special left-click modes are preserved on both buttons: while placing a building, repairing, selling, toggling power, planting a beacon, planning a path or targeting a superweapon, the left button performs that action and the right button cancels it, exactly as in vanilla. +- Enable it with `RightClickCommand=true`. + +```{note} +This only changes mouse behaviour; keyboard hotkeys are unaffected. It does not rebind any keys. +``` + +In `RA2MD.INI`: +```ini +[Phobos] +RightClickCommand=false ; boolean +``` + ### Select Box ![selectbox](_static/images/selectbox.png) diff --git a/docs/Whats-New.md b/docs/Whats-New.md index 505261d045..fd00bae3b0 100644 --- a/docs/Whats-New.md +++ b/docs/Whats-New.md @@ -140,7 +140,7 @@ ShowBriefing=true ; boolean DigitalDisplay.Enable=false ; boolean ShowDesignatorRange=false ; boolean PrioritySelectionFiltering=true ; boolean -ModernControls=false ; boolean +RightClickCommand=false ; boolean PriorityDeployFiltering=true ; boolean ShowPlacementPreview=yes ; boolean RealTimeTimers=false ; boolean @@ -387,7 +387,7 @@ HideShakeEffects=false ; boolean :open: #### New: -- [Modern control scheme (right-click to command)](User-Interface.md#modern-control-scheme) (by leosnake2208) +- [Right-click to command](User-Interface.md#right-click-to-command) (by leosnake2208) - [Allow using waypoints, area guard and attack move with aircraft](Fixed-or-Improved-Logics.md#extended-aircraft-missions) (by CrimRecya) - [Enhanced Straight trajectory](New-or-Enhanced-Logics.md#straight-trajectory) (by CrimRecya) - [Enable building production queue](User-Interface.md#building-production-queue) (by CrimRecya) diff --git a/src/Misc/ModernControls.cpp b/src/Misc/RightClickCommand.cpp similarity index 86% rename from src/Misc/ModernControls.cpp rename to src/Misc/RightClickCommand.cpp index ef43427e8c..98abe783ec 100644 --- a/src/Misc/ModernControls.cpp +++ b/src/Misc/RightClickCommand.cpp @@ -7,9 +7,9 @@ #include // pulls in the complete FootClass/TechnoClass definitions that // MapClass/DisplayClass inline abstract_casts require -// Modern control scheme: the right mouse button issues orders, the left mouse button only +// Right-click to command: the right mouse button issues orders, the left mouse button only // selects / deploys (and deselects when clicking empty ground), matching the control style -// of modern RTS games. Off by default; enable with [Phobos] -> ModernControls. +// of modern RTS games. Off by default; enable with [Phobos] -> RightClickCommand. // // The tactical mouse message handler is MouseClass method 0x6930A0, which dispatches Windows // mouse messages through a jump table (0x693410). Relevant cases (reverse-engineered): @@ -24,7 +24,7 @@ // 0x69323E, reusing 100% of the game's command (and network) logic - no argument // reconstruction is needed. -namespace ModernControls +namespace RightClickCommand { // A special LEFT-click mode is active - RMB must keep its vanilla "cancel" behaviour // (cancel building placement, leave repair/sell/power/beacon/superweapon targeting) and @@ -64,13 +64,13 @@ namespace ModernControls } } - // If modern controls are on and no special left-click mode is active, downgrade the + // If right-click-to-command is on and no special left-click mode is active, downgrade the // command action (in EAX, freshly returned by DecideAction) to None so the left button // only selects/deploys. Returns true if a command was actually neutralised (the click // landed on empty ground / an enemy - a command target, not a selectable own unit). static bool NeutraliseLeftCommand(REGISTERS* R) { - if (Phobos::Config::ModernControls && !InSpecialLeftClickMode()) + if (Phobos::Config::RightClickCommand && !InSpecialLeftClickMode()) { if (!IsLeftClickAllowed(static_cast(R->EAX()))) { @@ -92,11 +92,11 @@ namespace ModernControls // values but only passes them to 0x63AB00 without storing them, so we populate the slots // ourselves before jumping (otherwise ProcessClickCoords reads stale stack and the order // lands on the wrong cell). -DEFINE_HOOK(0x693397, TacticalMsgHandler_RButtonUp_ModernCommand, 0x6) +DEFINE_HOOK(0x693397, TacticalMsgHandler_RButtonUp_RightClickCommand, 0x6) { - if (Phobos::Config::ModernControls + if (Phobos::Config::RightClickCommand && ObjectClass::CurrentObjects.Count > 0 - && !ModernControls::InSpecialLeftClickMode()) + && !RightClickCommand::InSpecialLeftClickMode()) { // Raw packed window xy stashed by the RMB prologue at [esp+0x34]. const int packed = R->Stack(0x34); @@ -111,7 +111,7 @@ DEFINE_HOOK(0x693397, TacticalMsgHandler_RButtonUp_ModernCommand, 0x6) R->Stack(0x14, rawY - originY); // Tell the LBUTTONUP neutralise hook to leave this (RMB-issued) command alone. - ModernControls::RmbCommandInProgress = true; + RightClickCommand::RmbCommandInProgress = true; // Issue the order to the current selection instead of deselecting. return 0x69323E; @@ -124,9 +124,9 @@ DEFINE_HOOK(0x693397, TacticalMsgHandler_RButtonUp_ModernCommand, 0x6) // LBUTTONDOWN: neutralise a command action right after DecideAction so button-down does not // preview/issue a command. Stolen bytes: mov reg,[esp+..] + push eax (the possibly-modified // EAX is what the following push forwards to the applier). -DEFINE_HOOK(0x6931B4, TacticalMsgHandler_LButtonDown_ModernSelectOnly, 0x5) +DEFINE_HOOK(0x6931B4, TacticalMsgHandler_LButtonDown_RightClickSelectOnly, 0x5) { - ModernControls::NeutraliseLeftCommand(R); + RightClickCommand::NeutraliseLeftCommand(R); return 0; } @@ -135,16 +135,16 @@ DEFINE_HOOK(0x6931B4, TacticalMsgHandler_LButtonDown_ModernSelectOnly, 0x5) // (Self_Deploy) is not neutralised, so it selects/deploys as normal - no deselect there. // Completed band-selects never reach here (0x63A8E0 consumes them and early-exits at // 0x693408), so deselecting on a neutralised command is always correct. -DEFINE_HOOK(0x693276, TacticalMsgHandler_LButtonUp_ModernSelectOnly, 0x5) +DEFINE_HOOK(0x693276, TacticalMsgHandler_LButtonUp_RightClickSelectOnly, 0x5) { // If we arrived here via the RMB command redirect (0x69323E), let the order stand. - if (ModernControls::RmbCommandInProgress) + if (RightClickCommand::RmbCommandInProgress) { - ModernControls::RmbCommandInProgress = false; + RightClickCommand::RmbCommandInProgress = false; return 0; } - if (ModernControls::NeutraliseLeftCommand(R)) + if (RightClickCommand::NeutraliseLeftCommand(R)) MapClass::UnselectAll(); return 0; diff --git a/src/Phobos.INI.cpp b/src/Phobos.INI.cpp index 9582205194..498f58eb75 100644 --- a/src/Phobos.INI.cpp +++ b/src/Phobos.INI.cpp @@ -49,7 +49,7 @@ bool Phobos::Config::ToolTipDescriptions = true; bool Phobos::Config::ToolTipBlur = false; bool Phobos::Config::PrioritySelectionFiltering = true; bool Phobos::Config::PriorityDeployFiltering = true; -bool Phobos::Config::ModernControls = false; +bool Phobos::Config::RightClickCommand = false; bool Phobos::Config::TypeSelectUseIFVMode = true; bool Phobos::Config::DevelopmentCommands = true; bool Phobos::Config::SuperWeaponSidebarCommands = false; @@ -95,7 +95,7 @@ DEFINE_HOOK(0x5FACDF, OptionsClass_LoadSettings_LoadPhobosSettings, 0x5) Phobos::Config::ToolTipBlur = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "ToolTipBlur", false); Phobos::Config::PrioritySelectionFiltering = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "PrioritySelectionFiltering", true); Phobos::Config::PriorityDeployFiltering = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "PriorityDeployFiltering", true); - Phobos::Config::ModernControls = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "ModernControls", false); + Phobos::Config::RightClickCommand = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "RightClickCommand", false); Phobos::Config::TypeSelectUseIFVMode = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "TypeSelectUseIFVMode", true); Phobos::Config::ShowPlacementPreview = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "ShowPlacementPreview", true); Phobos::Config::MessageApplyHoverState = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "MessageApplyHoverState", false); diff --git a/src/Phobos.h b/src/Phobos.h index 75b171be39..1dd2e5def3 100644 --- a/src/Phobos.h +++ b/src/Phobos.h @@ -84,7 +84,7 @@ class Phobos static bool ToolTipBlur; static bool PrioritySelectionFiltering; static bool PriorityDeployFiltering; - static bool ModernControls; + static bool RightClickCommand; static bool TypeSelectUseIFVMode; static bool DevelopmentCommands; static bool SuperWeaponSidebarCommands; From 1709569bd0a9c4713cf11068b6068f4a0bd723c8 Mon Sep 17 00:00:00 2001 From: Igor Kolchinskii <33728971+leosnake2208@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:24:02 +0200 Subject: [PATCH 3/5] Fold multi-click type select into the right-click scheme Double and triple click only work once the left button stops commanding, so type select by multi-click moves here and is ignored unless RightClickCommand is on (TaranDahl's point on #2297). Take the clicked unit from ProcessClickCoords' output instead of CurrentObjects[0], which picked the wrong unit whenever several were selected. Add TypeSelectByMultiClick.Range (double-click reach in cells, negative for the whole screen) and TypeSelectByMultiClick.DeployDelay (how long the left button will not deploy after a click selected something), both asked for by TAK02. Apply the scheme to the minimap too. RadarClass::GetMouseAction reads the buttons out of its flags argument, so swapping the press/held/release bits there makes the right button command and the left button move the view, the other way around from vanilla. Co-Authored-By: Claude Opus 5 (1M context) --- CREDITS.md | 1 + Phobos.vcxproj | 1 + docs/User-Interface.md | 21 +++++ docs/Whats-New.md | 4 + src/Misc/MultiClickTypeSelect.cpp | 141 ++++++++++++++++++++++++++++++ src/Misc/RightClickCommand.cpp | 90 +++++++++++++++++-- src/Phobos.INI.cpp | 17 ++++ src/Phobos.h | 3 + 8 files changed, 270 insertions(+), 8 deletions(-) create mode 100644 src/Misc/MultiClickTypeSelect.cpp diff --git a/CREDITS.md b/CREDITS.md index 90d1db280d..ce8bf5dc4e 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -890,3 +890,4 @@ This page lists all the individual contributions to the project by their author. - Add `ClampToScreen` tag for `BannerType` to control whether banner position is clamped to the visible area - **Igor Kolchinskii (leosnake2208)**: - Right-click to command + - Type selection by double/triple-click diff --git a/Phobos.vcxproj b/Phobos.vcxproj index 9b93cd331e..6b99df2079 100644 --- a/Phobos.vcxproj +++ b/Phobos.vcxproj @@ -263,6 +263,7 @@ + diff --git a/docs/User-Interface.md b/docs/User-Interface.md index 010b44ed49..07a6e0294c 100644 --- a/docs/User-Interface.md +++ b/docs/User-Interface.md @@ -323,7 +323,9 @@ RealTimeTimers.Adaptive=false ; boolean - An optional control scheme matching modern RTS games: the **right mouse button** issues orders to the current selection (move, attack, enter, capture, etc.), while the **left mouse button** only selects, box-selects and self-deploys, and deselects when clicking empty ground. It reuses the game's own command dispatch, so all order and network logic is unchanged. - Special left-click modes are preserved on both buttons: while placing a building, repairing, selling, toggling power, planting a beacon, planning a path or targeting a superweapon, the left button performs that action and the right button cancels it, exactly as in vanilla. +- The minimap follows the same scheme: the right button orders the current selection to the clicked spot, the left button moves the view there. In vanilla it is the other way around, the left button commands and only moves the view when there is nothing to command. - Enable it with `RightClickCommand=true`. +- Because the left button no longer commands, double and triple clicks are free for [type selection](#type-selection-by-multi-click). ```{note} This only changes mouse behaviour; keyboard hotkeys are unaffected. It does not rebind any keys. @@ -466,6 +468,25 @@ BuildingTypeSelectable=false ; boolean Due to technical limitations, this feature is forcibly disabled without Ares. ``` +### Type selection by multi-click + +- Double-clicking a unit selects every unit of the same selection group near it; triple-clicking selects every unit of that group across the whole map. This adds the double/triple-click gesture common to modern RTS games next to the vanilla type-select hotkey (hold `T` and click). Only your own selectable units are affected, and the selection group is the same one the hotkey uses (the `[TechnoType] -> GroupAs` tag, falling back to the type's ID). +- Enable it with `TypeSelectByMultiClick=true`. It requires [right-click to command](#right-click-to-command) and is ignored without it, see the note below. +- `TypeSelectByMultiClick.Range` is how far a double-click reaches, in cells around the clicked unit. A negative value means everything currently drawn on screen. +- `TypeSelectByMultiClick.DeployDelay` is how long, in milliseconds, the left button refuses to deploy a unit after a click selected it. Without this delay the second click of a double-click would unpack an MCV instead of selecting its group. Set it to `0` to turn the delay off. + +```{note} +This needs the left mouse button to be select-only, so it only works together with `RightClickCommand=true`. With vanilla controls a click on an already selected unit is a command, so a double-click would deploy an MCV or an Allied GI rather than select the group. If you enable it anyway, it is turned off and a line is written to the debug log. +``` + +In `RA2MD.ini`: +```ini +[Phobos] +TypeSelectByMultiClick=false ; boolean +TypeSelectByMultiClick.Range=-1 ; integer, cells +TypeSelectByMultiClick.DeployDelay=500 ; integer, milliseconds +``` + ### Visual effects toggling - It is possible to toggle certain light flash effects off. These light flash effects include: diff --git a/docs/Whats-New.md b/docs/Whats-New.md index fd00bae3b0..9ef2464613 100644 --- a/docs/Whats-New.md +++ b/docs/Whats-New.md @@ -141,6 +141,9 @@ DigitalDisplay.Enable=false ; boolean ShowDesignatorRange=false ; boolean PrioritySelectionFiltering=true ; boolean RightClickCommand=false ; boolean +TypeSelectByMultiClick=false ; boolean +TypeSelectByMultiClick.Range=-1 ; integer, cells +TypeSelectByMultiClick.DeployDelay=500 ; integer, milliseconds PriorityDeployFiltering=true ; boolean ShowPlacementPreview=yes ; boolean RealTimeTimers=false ; boolean @@ -388,6 +391,7 @@ HideShakeEffects=false ; boolean #### New: - [Right-click to command](User-Interface.md#right-click-to-command) (by leosnake2208) +- [Type selection by double/triple-click](User-Interface.md#type-selection-by-multi-click) (by leosnake2208) - [Allow using waypoints, area guard and attack move with aircraft](Fixed-or-Improved-Logics.md#extended-aircraft-missions) (by CrimRecya) - [Enhanced Straight trajectory](New-or-Enhanced-Logics.md#straight-trajectory) (by CrimRecya) - [Enable building production queue](User-Interface.md#building-production-queue) (by CrimRecya) diff --git a/src/Misc/MultiClickTypeSelect.cpp b/src/Misc/MultiClickTypeSelect.cpp new file mode 100644 index 0000000000..e831e62969 --- /dev/null +++ b/src/Misc/MultiClickTypeSelect.cpp @@ -0,0 +1,141 @@ +#include + +#include +#include +#include +#include + +// Multi-click type selection: +// * double-click a unit -> select every unit of its selection group nearby; +// * triple-click a unit -> select that group across the whole map. +// +// Vanilla only has the type-select hotkey (hold T, then click or drag); there is no +// double-click equivalent, a double click in the tactical area falls through to the message +// handler's default case. Grouping reuses TechnoTypeExt::GetSelectionGroupID / +// HasSelectionGroupID (the GroupAs tag, with the type ID as fallback), so it stays consistent +// with the hotkey type select in Selection.cpp. +// +// This needs RightClickCommand. With the vanilla left button a click on an already selected +// unit is a command, so a double click would deploy an MCV or an Allied GI instead of +// selecting. Phobos.INI.cpp turns the setting off if RightClickCommand is not enabled. + +namespace RightClickCommand +{ + // Defined in RightClickCommand.cpp. Set by the RMB command redirect, cleared here. + extern bool RmbCommandInProgress; +} + +namespace MultiClickTypeSelect +{ + // Clicks within GetDoubleClickTime() and this many screen pixels of the previous one + // count as part of the same streak. + static constexpr int PositionTolerance = 4; + + static DWORD LastClickTick = 0; + static POINT LastClickPos = { -9999, -9999 }; + static int ClickStreak = 0; + + // Mirrors ExtSelection::ObjectClass_IsSelectable (Selection.cpp): an own, alive, currently + // selectable object. + static bool IsOwnSelectable(TechnoClass* pTechno) + { + const auto pOwner = pTechno->GetOwningHouse(); + return pOwner && pOwner->IsControlledByCurrentPlayer() + && pTechno->CanBeSelected() && pTechno->CanBeSelectedNow() + && !pTechno->InLimbo; + } + + // Add to the current selection every own, selectable mobile unit sharing the clicked + // unit's selection group. wholeMap takes the whole map; otherwise the reach is + // TypeSelectByMultiClick.Range cells around the clicked unit, or, if that is negative, + // everything drawn in the tactical viewport. + static void SelectSameGroup(FootClass* pClicked, bool wholeMap) + { + const char* groupID = TechnoTypeExt::GetSelectionGroupID(pClicked->GetTechnoType()); + const int rangeCells = Phobos::Config::TypeSelectByMultiClick_Range; + + for (auto const pTechno : TechnoClass::Array) + { + const auto pFoot = abstract_cast(pTechno); + + if (!pFoot || pFoot->IsSelected || !IsOwnSelectable(pFoot)) + continue; + + if (!TechnoTypeExt::HasSelectionGroupID(pFoot->GetTechnoType(), groupID)) + continue; + + if (!wholeMap) + { + if (rangeCells >= 0) + { + if (pClicked->DistanceFrom(pFoot) > rangeCells * Unsorted::LeptonsPerCell) + continue; + } + else if (!TacticalClass::Instance->CoordsToClient(pFoot->GetCoords()).second) + { + continue; + } + } + + pFoot->Select(); + } + } + + // Update the click streak from the current cursor time/position and act on it. pClicked is + // the object the click landed on, or null for empty ground. + static void HandleClick(FootClass* pClicked) + { + POINT pos { 0, 0 }; + GetCursorPos(&pos); + const DWORD now = GetTickCount(); + + const int dx = pos.x - LastClickPos.x; + const int dy = pos.y - LastClickPos.y; + const bool sameSpot = dx >= -PositionTolerance && dx <= PositionTolerance + && dy >= -PositionTolerance && dy <= PositionTolerance; + + if (now - LastClickTick <= GetDoubleClickTime() && sameSpot) + ClickStreak = ClickStreak < 3 ? ClickStreak + 1 : 3; + else + ClickStreak = 1; + + LastClickTick = now; + LastClickPos = pos; + + if (!pClicked) // only mobile units drive type select + return; + + if (ClickStreak == 2) + SelectSameGroup(pClicked, false); + else if (ClickStreak == 3) + SelectSameGroup(pClicked, true); + } +} + +// Tactical LBUTTONUP handler, just after the click's own selection has been applied (call to +// 0x4AB9B0) and before the drag flag is cleared. This point is only reached by a genuine +// single click - a completed rubber-band selection returns earlier. Stolen bytes: +// mov byte ptr [esi+0x555A], bl (absolute operand, safe to relocate). +DEFINE_HOOK(0x693290, TacticalMsgHandler_LButtonUp_MultiClickTypeSelect, 0x6) +{ + // The RMB command redirect (RightClickCommand.cpp) ends here too. Consume the flag and + // skip, so an RMB order is not counted as a left click for the streak. + if (RightClickCommand::RmbCommandInProgress) + { + RightClickCommand::RmbCommandInProgress = false; + return 0; + } + + if (!Phobos::Config::TypeSelectByMultiClick) + return 0; + + // The object this click landed on, as ProcessClickCoords resolved it at 0x69325E and + // DecideAction and the applier were given it. ESP here equals ESP at the start of the + // command dispatch (0x69323E), which is where that output slot is addressed from. + // Not CurrentObjects[0]: with several units selected that is not the clicked one. + const auto pClicked = abstract_cast(R->Stack(0x2C)); + + MultiClickTypeSelect::HandleClick(pClicked); + + return 0; +} diff --git a/src/Misc/RightClickCommand.cpp b/src/Misc/RightClickCommand.cpp index 98abe783ec..ad673730e5 100644 --- a/src/Misc/RightClickCommand.cpp +++ b/src/Misc/RightClickCommand.cpp @@ -43,9 +43,13 @@ namespace RightClickCommand // Set by the RBUTTONUP command hook right before it jumps into the shared LMB-up command // dispatch (0x69323E), which flows through the LBUTTONUP neutralise hook at 0x693276. - // Without this flag that hook would downgrade the RMB-issued order to None; the LBUTTONUP - // hook consumes (clears) it. - static bool RmbCommandInProgress = false; + // Without this flag that hook would downgrade the RMB-issued order to None. Not static: + // MultiClickTypeSelect.cpp reads and clears it at 0x693290, the last point the redirect + // passes through. + bool RmbCommandInProgress = false; + + // Tick of the click that last selected something, used by HoldsOffDeploy below. + static DWORD LastSelectTick = 0; // Actions the LEFT button may still perform: selection and self-deploy only. Everything // else (Move/Attack/Enter/Harvest/Capture/Guard/...) is a command and belongs to the @@ -80,6 +84,55 @@ namespace RightClickCommand } return false; } + + // A deployable unit is deployed by clicking it again once it is selected, which collides + // with the double-click type select: the second click would unpack the MCV instead. So + // for a short while after a click selected something, the left button does not deploy. + // Same trick Emperor: Battle for Dune uses. Only active with TypeSelectByMultiClick on. + static bool HoldsOffDeploy(Action action) + { + const int delay = Phobos::Config::TypeSelectByMultiClick_DeployDelay; + + if (action != Action::Self_Deploy || !Phobos::Config::TypeSelectByMultiClick || delay <= 0) + return false; + + return GetTickCount() - LastSelectTick <= static_cast(delay); + } + + // Mouse flags RadarClass::GetMouseAction (0x6539D0) is called with, in its first stack + // argument. The "up" bits mean the button is not down, i.e. the cursor is just hovering. + enum RadarInput : BYTE + { + LeftPress = 0x01, LeftHeld = 0x02, LeftRelease = 0x04, LeftUp = 0x08, + RightPress = 0x10, RightHeld = 0x20, RightRelease = 0x40, RightUp = 0x80, + }; + + // Swap the two buttons for the minimap, keeping the hover bits as they are so the cursor + // still updates while moving over the radar. + static BYTE SwapRadarButtons(BYTE flags) + { + const BYTE left = flags & (LeftPress | LeftHeld | LeftRelease); + const BYTE right = flags & (RightPress | RightHeld | RightRelease); + + return static_cast((flags & (LeftUp | RightUp)) | (left << 4) | (right >> 4)); + } + + // Runs after DecideAction on both left button hooks. Returns true if the action was + // downgraded to a command-less None because of the deploy hold-off, which unlike a + // neutralised command must NOT deselect - the unit stays selected and waits. + static bool ApplyDeployHoldOff(REGISTERS* R) + { + const auto action = static_cast(R->EAX()); + + if (action == Action::Select || action == Action::ToggleSelect) + LastSelectTick = GetTickCount(); + + if (!HoldsOffDeploy(action)) + return false; + + R->EAX(static_cast(Action::None)); + return true; + } } // RBUTTONUP handler, just past its "press/drag in progress" gate (cmp [this+0x555A],bl / @@ -121,12 +174,31 @@ DEFINE_HOOK(0x693397, TacticalMsgHandler_RButtonUp_RightClickCommand, 0x6) return 0; } +// The minimap needs the same treatment. RadarClass::GetMouseAction (0x6539D0) decides what a +// click on the radar does, and vanilla already commands from there: the LEFT button runs the +// same applier the tactical view uses (0x4AB9B0, called at 0x653D58) and only moves the view +// when there is nothing to command, while the RIGHT button just moves the view. The whole +// function reads the buttons out of its flags argument, so swapping the button bits there +// once turns it around: right commands, left moves the view. +// +// Hooked right at the top, before the first read of the argument. Stolen bytes: mov dl, +// byte ptr [esp+0x48] (the flags we just rewrote) + push ebx. +DEFINE_HOOK(0x6539D3, RadarClass_GetMouseAction_RightClickCommand, 0x5) +{ + if (Phobos::Config::RightClickCommand && !RightClickCommand::InSpecialLeftClickMode()) + R->Stack8(0x48, RightClickCommand::SwapRadarButtons(R->Stack8(0x48))); + + return 0; +} + // LBUTTONDOWN: neutralise a command action right after DecideAction so button-down does not // preview/issue a command. Stolen bytes: mov reg,[esp+..] + push eax (the possibly-modified // EAX is what the following push forwards to the applier). DEFINE_HOOK(0x6931B4, TacticalMsgHandler_LButtonDown_RightClickSelectOnly, 0x5) { - RightClickCommand::NeutraliseLeftCommand(R); + if (!RightClickCommand::ApplyDeployHoldOff(R)) + RightClickCommand::NeutraliseLeftCommand(R); + return 0; } @@ -137,12 +209,14 @@ DEFINE_HOOK(0x6931B4, TacticalMsgHandler_LButtonDown_RightClickSelectOnly, 0x5) // 0x693408), so deselecting on a neutralised command is always correct. DEFINE_HOOK(0x693276, TacticalMsgHandler_LButtonUp_RightClickSelectOnly, 0x5) { - // If we arrived here via the RMB command redirect (0x69323E), let the order stand. + // If we arrived here via the RMB command redirect (0x69323E), let the order stand. The + // flag is cleared further along the same path, at 0x693290 in MultiClickTypeSelect.cpp. if (RightClickCommand::RmbCommandInProgress) - { - RightClickCommand::RmbCommandInProgress = false; return 0; - } + + // A held-off deploy leaves the unit selected, so no deselect here. + if (RightClickCommand::ApplyDeployHoldOff(R)) + return 0; if (RightClickCommand::NeutraliseLeftCommand(R)) MapClass::UnselectAll(); diff --git a/src/Phobos.INI.cpp b/src/Phobos.INI.cpp index 498f58eb75..a07aa1a5f3 100644 --- a/src/Phobos.INI.cpp +++ b/src/Phobos.INI.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -50,6 +51,9 @@ bool Phobos::Config::ToolTipBlur = false; bool Phobos::Config::PrioritySelectionFiltering = true; bool Phobos::Config::PriorityDeployFiltering = true; bool Phobos::Config::RightClickCommand = false; +bool Phobos::Config::TypeSelectByMultiClick = false; +int Phobos::Config::TypeSelectByMultiClick_Range = -1; +int Phobos::Config::TypeSelectByMultiClick_DeployDelay = 500; bool Phobos::Config::TypeSelectUseIFVMode = true; bool Phobos::Config::DevelopmentCommands = true; bool Phobos::Config::SuperWeaponSidebarCommands = false; @@ -96,6 +100,19 @@ DEFINE_HOOK(0x5FACDF, OptionsClass_LoadSettings_LoadPhobosSettings, 0x5) Phobos::Config::PrioritySelectionFiltering = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "PrioritySelectionFiltering", true); Phobos::Config::PriorityDeployFiltering = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "PriorityDeployFiltering", true); Phobos::Config::RightClickCommand = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "RightClickCommand", false); + Phobos::Config::TypeSelectByMultiClick = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "TypeSelectByMultiClick", false); + Phobos::Config::TypeSelectByMultiClick_Range = CCINIClass::INI_RA2MD.ReadInteger(phobosSection, "TypeSelectByMultiClick.Range", -1); + Phobos::Config::TypeSelectByMultiClick_DeployDelay = CCINIClass::INI_RA2MD.ReadInteger(phobosSection, "TypeSelectByMultiClick.DeployDelay", 500); + + // Multi-click type select only works when the left button is select-only. With vanilla + // controls the second click already commands the unit, so it would deploy an MCV or an + // Allied GI instead of selecting the group. + if (Phobos::Config::TypeSelectByMultiClick && !Phobos::Config::RightClickCommand) + { + Debug::Log("[Phobos] TypeSelectByMultiClick requires RightClickCommand=true, disabling it.\n"); + Phobos::Config::TypeSelectByMultiClick = false; + } + Phobos::Config::TypeSelectUseIFVMode = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "TypeSelectUseIFVMode", true); Phobos::Config::ShowPlacementPreview = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "ShowPlacementPreview", true); Phobos::Config::MessageApplyHoverState = CCINIClass::INI_RA2MD.ReadBool(phobosSection, "MessageApplyHoverState", false); diff --git a/src/Phobos.h b/src/Phobos.h index 1dd2e5def3..00d6d653ee 100644 --- a/src/Phobos.h +++ b/src/Phobos.h @@ -85,6 +85,9 @@ class Phobos static bool PrioritySelectionFiltering; static bool PriorityDeployFiltering; static bool RightClickCommand; + static bool TypeSelectByMultiClick; + static int TypeSelectByMultiClick_Range; + static int TypeSelectByMultiClick_DeployDelay; static bool TypeSelectUseIFVMode; static bool DevelopmentCommands; static bool SuperWeaponSidebarCommands; From 928ce63e26c1d80babba73b3a96a722ab497002e Mon Sep 17 00:00:00 2001 From: Igor Kolchinskii <33728971+leosnake2208@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:18:30 +0200 Subject: [PATCH 4/5] Fix the left click crash and the deploy hold-off Clicking empty ground crashed: the clicked object is read from ProcessClickCoords' output slot, which is null there, and abstract_cast's second argument skips the null check instead of making the cast stricter. The hold-off never armed when it mattered. It started the timer on a Select action, but a click on a unit that is part of a selection is reported as NoMove just as often, while the applier narrows the selection down to that unit all the same. The next click then saw a stale timer and unpacked the unit. Decide by the clicked object instead: a click reselects when the unit was not selected yet, or when it was one of several, and such a click starts the timer and never deploys. Co-Authored-By: Claude Opus 5 (1M context) --- src/Misc/MultiClickTypeSelect.cpp | 3 ++- src/Misc/RightClickCommand.cpp | 40 +++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/Misc/MultiClickTypeSelect.cpp b/src/Misc/MultiClickTypeSelect.cpp index e831e62969..5aeb4d7dcd 100644 --- a/src/Misc/MultiClickTypeSelect.cpp +++ b/src/Misc/MultiClickTypeSelect.cpp @@ -133,7 +133,8 @@ DEFINE_HOOK(0x693290, TacticalMsgHandler_LButtonUp_MultiClickTypeSelect, 0x6) // DecideAction and the applier were given it. ESP here equals ESP at the start of the // command dispatch (0x69323E), which is where that output slot is addressed from. // Not CurrentObjects[0]: with several units selected that is not the clicked one. - const auto pClicked = abstract_cast(R->Stack(0x2C)); + // Null when the click landed on empty ground, so this cast must keep its null check. + const auto pClicked = abstract_cast(R->Stack(0x2C)); MultiClickTypeSelect::HandleClick(pClicked); diff --git a/src/Misc/RightClickCommand.cpp b/src/Misc/RightClickCommand.cpp index ad673730e5..c5bb08033e 100644 --- a/src/Misc/RightClickCommand.cpp +++ b/src/Misc/RightClickCommand.cpp @@ -89,14 +89,17 @@ namespace RightClickCommand // with the double-click type select: the second click would unpack the MCV instead. So // for a short while after a click selected something, the left button does not deploy. // Same trick Emperor: Battle for Dune uses. Only active with TypeSelectByMultiClick on. - static bool HoldsOffDeploy(Action action) + static bool DeployHoldOffEnabled() { - const int delay = Phobos::Config::TypeSelectByMultiClick_DeployDelay; + return Phobos::Config::TypeSelectByMultiClick + && Phobos::Config::TypeSelectByMultiClick_DeployDelay > 0; + } - if (action != Action::Self_Deploy || !Phobos::Config::TypeSelectByMultiClick || delay <= 0) - return false; + static bool HoldsOffDeploy() + { + const auto delay = static_cast(Phobos::Config::TypeSelectByMultiClick_DeployDelay); - return GetTickCount() - LastSelectTick <= static_cast(delay); + return GetTickCount() - LastSelectTick <= delay; } // Mouse flags RadarClass::GetMouseAction (0x6539D0) is called with, in its first stack @@ -124,10 +127,33 @@ namespace RightClickCommand { const auto action = static_cast(R->EAX()); - if (action == Action::Select || action == Action::ToggleSelect) + // The object the click landed on, as ProcessClickCoords resolved it - the same slot the + // game itself reads at 0x693276 to hand it to the applier. Both left button hooks sit at + // the same stack depth, so the offset holds for either. Null on empty ground. + const auto pClicked = R->Stack(0x30); + + // Whether this click (re)selects the clicked unit: either it was not selected yet, or it + // was part of a bigger selection which now narrows down to it. DecideAction reports that + // as Select, but also as NoMove or Self_Deploy depending on what sits under the cursor, + // so the action alone is not a usable signal - go by the clicked object. Getting this + // wrong leaves the hold-off unarmed and the next click unpacks the unit. + const bool reselects = pClicked + && (!pClicked->IsSelected || ObjectClass::CurrentObjects.Count > 1); + + if (reselects) LastSelectTick = GetTickCount(); - if (!HoldsOffDeploy(action)) + if (action != Action::Self_Deploy || !DeployHoldOffEnabled()) + return false; + + // A click that reselects is the first click of a possible double click, never a deploy. + if (reselects) + { + R->EAX(static_cast(Action::Select)); + return true; + } + + if (!HoldsOffDeploy()) return false; R->EAX(static_cast(Action::None)); From b9ab5b11ab0125ee08b71731a2e3b1299a62fa67 Mon Sep 17 00:00:00 2001 From: Igor Kolchinskii <33728971+leosnake2208@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:36:44 +0200 Subject: [PATCH 5/5] Document the deploy hold-off limit The hold-off is armed by a click that selects, so a unit already selected on its own still deploys on the first click of a double click. Note it in the docs and next to the code, so it is not mistaken for a bug. --- docs/User-Interface.md | 1 + src/Misc/RightClickCommand.cpp | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/docs/User-Interface.md b/docs/User-Interface.md index b759b09775..03fcfd8927 100644 --- a/docs/User-Interface.md +++ b/docs/User-Interface.md @@ -489,6 +489,7 @@ Due to technical limitations, this feature is forcibly disabled without Ares. - Enable it with `TypeSelectByMultiClick=true`. It requires [right-click to command](#right-click-to-command) and is ignored without it, see the note below. - `TypeSelectByMultiClick.Range` is how far a double-click reaches, in cells around the clicked unit. A negative value means everything currently drawn on screen. - `TypeSelectByMultiClick.DeployDelay` is how long, in milliseconds, the left button refuses to deploy a unit after a click selected it. Without this delay the second click of a double-click would unpack an MCV instead of selecting its group. Set it to `0` to turn the delay off. + - The delay starts when a click selects something, so it does not cover a unit that is already selected on its own. Double-clicking such a unit still deploys it on the first click, because at that moment nothing tells the game that a second click is coming. ```{note} This needs the left mouse button to be select-only, so it only works together with `RightClickCommand=true`. With vanilla controls a click on an already selected unit is a command, so a double-click would deploy an MCV or an Allied GI rather than select the group. If you enable it anyway, it is turned off and a line is written to the debug log. diff --git a/src/Misc/RightClickCommand.cpp b/src/Misc/RightClickCommand.cpp index c5bb08033e..344820c38f 100644 --- a/src/Misc/RightClickCommand.cpp +++ b/src/Misc/RightClickCommand.cpp @@ -89,6 +89,11 @@ namespace RightClickCommand // with the double-click type select: the second click would unpack the MCV instead. So // for a short while after a click selected something, the left button does not deploy. // Same trick Emperor: Battle for Dune uses. Only active with TypeSelectByMultiClick on. + // + // Known limit: the delay is armed by a click that selects, so a unit that is already + // selected on its own is not covered - it deploys on the first click of a double click. + // Covering that means holding the deploy back for the delay and cancelling it on the + // second click, which needs a per-frame hook to fire the deploy afterwards. static bool DeployHoldOffEnabled() { return Phobos::Config::TypeSelectByMultiClick