From 41ca5180cc263c8e8f1c2e6e4ba50c8ea7e79da6 Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:45:15 +0300 Subject: [PATCH 1/3] feat(client): Add EasyMilitaryDrag to leave builders out of drag selections Options.ini: EasyMilitaryDrag = Yes drops builders from a drag selection, so boxing over a base picks up the army without dragging workers off their jobs. Covers KINDOF_DOZER and KINDOF_IGNORES_SELECT_ALL - the same kinds Select All already disqualifies - which also catches GLA Workers. Only drags are affected: point selection, double click, control groups and select-matching all behave as before. Holding Ctrl inverts the filter for that one drag, boxing only the builders, and cancels force attack mode for that selection since Ctrl drives both. A box holding only builders selects them after all via a second pass with the filter suspended - tested against units surviving, not list emptiness, so a building in the box cannot silently win over the workers standing next to it. Works in both Zero Hour and Generals: everything lives in the shared selection code. --- .../Include/Common/OptionPreferences.h | 1 + .../Include/GameClient/SelectionInfo.h | 17 +++++ .../Source/Common/OptionPreferences.cpp | 15 ++++ .../MessageStream/SelectionXlat.cpp | 36 +++++++++ .../Source/GameClient/SelectionInfo.cpp | 74 +++++++++++++++++++ 5 files changed, 143 insertions(+) diff --git a/Core/GameEngine/Include/Common/OptionPreferences.h b/Core/GameEngine/Include/Common/OptionPreferences.h index 85aba4228be..2311862b272 100644 --- a/Core/GameEngine/Include/Common/OptionPreferences.h +++ b/Core/GameEngine/Include/Common/OptionPreferences.h @@ -76,6 +76,7 @@ class OptionPreferences : public UserPreferences Real getScrollFactor(); Bool getDrawScrollAnchor(); Bool getMoveScrollAnchor(); + Bool getEasyMilitaryDragEnabled() const; Bool getCursorCaptureEnabledInWindowedGame() const; Bool getCursorCaptureEnabledInWindowedMenu() const; Bool getCursorCaptureEnabledInFullscreenGame() const; diff --git a/Core/GameEngine/Include/GameClient/SelectionInfo.h b/Core/GameEngine/Include/GameClient/SelectionInfo.h index 509c5923571..c21928f3622 100644 --- a/Core/GameEngine/Include/GameClient/SelectionInfo.h +++ b/Core/GameEngine/Include/GameClient/SelectionInfo.h @@ -68,12 +68,29 @@ struct PickDrawableStruct Bool isPointSelection; Bool forceAttackMode; + // TheSuperHackers @feature EasyMilitaryDrag leaves builders out of a drag selection. Cached here + // rather than read per drawable, because the constructor runs once per selection while the + // callback runs for every drawable in the region. + Bool easyMilitaryDrag; + // Set instead of easyMilitaryDrag when Ctrl is held: select only the builders the option + // normally skips, so they can still be boxed deliberately. + Bool easyMilitaryDragInverted; + // Set for a second pass when the first found nothing, which suspends the filter entirely so a + // box holding only builders still selects them rather than coming back empty. + Bool easyMilitaryDragDisabled; + // Note, this is OR'd with the things we are attempting to select. KindOfMaskType kindofsToMatch; PickDrawableStruct(); }; +//------------------------------------------------------------------------------------------------- +// TheSuperHackers @feature TRUE while a Ctrl held drag should be treated as an inverted +// EasyMilitaryDrag selection rather than as force attack targeting. Ctrl drives both, so every +// place that asks "are we force attacking?" during a drag has to agree on the answer. +extern Bool isEasyMilitaryDragInvertedActive( Bool selectionIsPoint ); + //------------------------------------------------------------------------------------------------- extern Bool contextCommandForNewSelection(const DrawableList *currentlySelectedDrawables, const DrawableList *newlySelectedDrawables, diff --git a/Core/GameEngine/Source/Common/OptionPreferences.cpp b/Core/GameEngine/Source/Common/OptionPreferences.cpp index e681ef8b192..e4a94c5bcc5 100644 --- a/Core/GameEngine/Source/Common/OptionPreferences.cpp +++ b/Core/GameEngine/Source/Common/OptionPreferences.cpp @@ -216,6 +216,21 @@ Bool OptionPreferences::getRightMouseScrollWithAlternateMouseEnabled() const return FALSE; } +// TheSuperHackers @feature Options.ini: EasyMilitaryDrag = Yes leaves builders out of a drag +// selection, so boxing over a base picks up the army without dragging them along. Covers +// KINDOF_DOZER and KINDOF_IGNORES_SELECT_ALL, the same kinds Select All already disqualifies. +Bool OptionPreferences::getEasyMilitaryDragEnabled() const +{ + OptionPreferences::const_iterator it = find("EasyMilitaryDrag"); + if (it == end()) + return FALSE; + + if (stricmp(it->second.str(), "yes") == 0) { + return TRUE; + } + return FALSE; +} + Bool OptionPreferences::getRetaliationModeEnabled() { OptionPreferences::const_iterator it = find("Retaliation"); diff --git a/Core/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp b/Core/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp index 321423df5b1..885923668cc 100644 --- a/Core/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp +++ b/Core/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp @@ -740,6 +740,42 @@ GameMessageDisposition SelectionTranslator::onMouseLeftClick(MAYBE_UNUSED const pds.isPointSelection = isPoint; TheTacticalView->iterateDrawablesInRegion(&selectionRegion, addDrawableToList, &pds); + // TheSuperHackers @feature EasyMilitaryDrag drops builders from a drag, but if that leaves + // no actual units then the box held only builders, and refusing to select them is just an + // unresponsive click. Run the region again with the filter suspended so they are selected + // after all. + // + // The test is "no units survived", not "the list is empty", because structures are never + // filtered and so stay in the list. Dragging over workers standing next to a building left + // the building as the only survivor, and the selection logic below has a branch that hands + // you a building when it is the only selectable thing in the box -- so the drag silently + // grabbed the building instead of the workers. + // + // Deliberately not done for the Ctrl inverted drag: there the player has explicitly asked + // for the builders, so grabbing the army instead would be the opposite of the request. + if (pds.easyMilitaryDrag) + { + Bool anyNonStructure = FALSE; + for (DrawableListIt it = drawablesThatWillSelect.begin(); + it != drawablesThatWillSelect.end(); ++it) + { + const Drawable *d = *it; + if (d && !d->isKindOf(KINDOF_STRUCTURE)) + { + anyNonStructure = TRUE; + break; + } + } + + if (!anyNonStructure) + { + // start clean, or the structures already gathered would be listed twice + drawablesThatWillSelect.clear(); + pds.easyMilitaryDragDisabled = TRUE; + TheTacticalView->iterateDrawablesInRegion(&selectionRegion, addDrawableToList, &pds); + } + } + if (drawablesThatWillSelect.empty()) { return KEEP_MESSAGE; diff --git a/Core/GameEngine/Source/GameClient/SelectionInfo.cpp b/Core/GameEngine/Source/GameClient/SelectionInfo.cpp index e5e273f4722..6a3b0eb7212 100644 --- a/Core/GameEngine/Source/GameClient/SelectionInfo.cpp +++ b/Core/GameEngine/Source/GameClient/SelectionInfo.cpp @@ -28,6 +28,8 @@ #include "GameLogic/Module/ContainModule.h" #include "Common/ActionManager.h" +// TheSuperHackers @feature for the EasyMilitaryDrag option +#include "Common/OptionPreferences.h" #include "Common/ThingTemplate.h" #include "Common/PlayerList.h" #include "Common/Player.h" @@ -38,6 +40,8 @@ #include "GameClient/Drawable.h" #include "GameClient/GameClient.h" #include "GameClient/KeyDefs.h" +// TheSuperHackers @feature for the EasyMilitaryDrag ctrl override +#include "GameClient/Keyboard.h" //------------------------------------------------------------------------------------------------- @@ -62,10 +66,54 @@ SelectionInfo::SelectionInfo() : selectFriends(FALSE) { } +//------------------------------------------------------------------------------------------------- +// TheSuperHackers @feature See SelectionInfo.h. A point selection is never an inverted drag, so +// plain Ctrl clicking still force attacks exactly as it always did. +Bool isEasyMilitaryDragInvertedActive( Bool selectionIsPoint ) +{ + if (selectionIsPoint) + return FALSE; + + if (!TheKeyboard || !TheKeyboard->isCtrl()) + return FALSE; + + OptionPreferences optionPref; + return optionPref.getEasyMilitaryDragEnabled(); +} + //------------------------------------------------------------------------------------------------- PickDrawableStruct::PickDrawableStruct() : drawableListToFill(nullptr), isPointSelection(FALSE) { forceAttackMode = TheInGameUI->isInForceAttackMode(); + + // TheSuperHackers @feature Read once per selection, not once per drawable. Holding Ctrl + // inverts the filter for this one drag, so the builders it normally skips are exactly what + // gets boxed -- the escape hatch for grabbing them deliberately. + // + // Ctrl is also what puts the game into force attack mode, so an inverted drag has to cancel + // that for this selection. Otherwise the click is treated as attack targeting and nothing is + // selected at all. Force attack has nothing to act on here anyway: this only engages while + // dragging a box, and what it selects is your own builders. + easyMilitaryDrag = FALSE; + easyMilitaryDragInverted = FALSE; + easyMilitaryDragDisabled = FALSE; + { + OptionPreferences optionPref; + if (optionPref.getEasyMilitaryDragEnabled()) + { + if (TheKeyboard && TheKeyboard->isCtrl()) + easyMilitaryDragInverted = TRUE; + else + easyMilitaryDrag = TRUE; + } + } + + // isPointSelection is set by the caller after construction, so this uses the flag resolved + // above rather than isEasyMilitaryDragInvertedActive. The two agree for drags, which is the + // only case that reaches the filter. + if (easyMilitaryDragInverted) + forceAttackMode = FALSE; + UnsignedInt pickType = getPickTypesForContext(forceAttackMode); translatePickTypesToKindof(pickType, kindofsToMatch); if (!forceAttackMode) @@ -90,6 +138,11 @@ extern Bool contextCommandForNewSelection(const DrawableList *currentlySelectedD Bool forceFire = TheInGameUI->isInForceAttackMode(); Bool forceMove = TheInGameUI->isInForceMoveToMode(); + // TheSuperHackers @feature An inverted EasyMilitaryDrag is a selection, not force attack + // targeting, even though Ctrl is what triggers both. + if (isEasyMilitaryDragInvertedActive(selectionIsPoint)) + forceFire = FALSE; + if (forceFire || forceMove) { return FALSE; } @@ -360,6 +413,27 @@ Bool addDrawableToList( Drawable *draw, void *userData ) if (!draw->getTemplate()->isAnyKindOf(pds->kindofsToMatch)) return FALSE; + // TheSuperHackers @feature EasyMilitaryDrag leaves builders out of a drag selection, so boxing + // over your own base picks up the army without dragging them off their work. Only drags are + // affected -- a point selection still picks them normally, as do double click, control groups + // and the select-matching hotkeys. + // + // The two kinds mirror what Select All already disqualifies: KINDOF_DOZER, and the explicit + // KINDOF_IGNORES_SELECT_ALL marker a mod can put on anything else it wants left alone. Note + // KINDOF_DOZER also catches GLA Workers, which carry it alongside KINDOF_INFANTRY and + // KINDOF_HARVESTER. + if (!pds->easyMilitaryDragDisabled && + !pds->isPointSelection && (pds->easyMilitaryDrag || pds->easyMilitaryDragInverted)) + { + const Bool isBuilder = + draw->isKindOf(KINDOF_DOZER) || draw->isKindOf(KINDOF_IGNORES_SELECT_ALL); + + // normally drop the builders; with Ctrl held, drop everything else instead + const Bool wantBuilders = pds->easyMilitaryDragInverted; + if (isBuilder != wantBuilders) + return FALSE; + } + if (!draw->isSelectable()) { const Object *obj = draw->getObject(); From 5cd377249f00d7696807fbdad1d7687adb7cb08b Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:54:22 +0300 Subject: [PATCH 2/3] fix(client): Cache the drag option and count only owned units in the fallback Addresses both review findings: - Constructing OptionPreferences reparses Options.ini from disk, and the selection path did it on every click or drag, twice with Ctrl held. The option now lives in GlobalData next to the other cached client options and refreshes with them; the input path reads the cached copy. - The builders-only fallback tested for any surviving non-structure, but a foreign unit in the box satisfies that test and is then rejected by the ownership filtering downstream - suppressing the fallback and leaving the boxed builders unselected. Only locally controlled units count now. --- .../MessageStream/SelectionXlat.cpp | 6 +++++- .../Source/GameClient/SelectionInfo.cpp | 20 +++++++++---------- .../GameEngine/Include/Common/GlobalData.h | 2 ++ .../GameEngine/Source/Common/GlobalData.cpp | 2 ++ .../GameEngine/Include/Common/GlobalData.h | 2 ++ .../GameEngine/Source/Common/GlobalData.cpp | 2 ++ 6 files changed, 22 insertions(+), 12 deletions(-) diff --git a/Core/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp b/Core/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp index 885923668cc..6236e2e1579 100644 --- a/Core/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp +++ b/Core/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp @@ -759,8 +759,12 @@ GameMessageDisposition SelectionTranslator::onMouseLeftClick(MAYBE_UNUSED const for (DrawableListIt it = drawablesThatWillSelect.begin(); it != drawablesThatWillSelect.end(); ++it) { + // Only the player's own units count: a foreign drawable in the box is rejected by the + // ownership filtering downstream, so letting it satisfy this test would suppress the + // builders-only pass and leave the drag selecting nothing. const Drawable *d = *it; - if (d && !d->isKindOf(KINDOF_STRUCTURE)) + if (d && !d->isKindOf(KINDOF_STRUCTURE) && + d->getObject() && d->getObject()->isLocallyControlled()) { anyNonStructure = TRUE; break; diff --git a/Core/GameEngine/Source/GameClient/SelectionInfo.cpp b/Core/GameEngine/Source/GameClient/SelectionInfo.cpp index 6a3b0eb7212..b75a4c7591a 100644 --- a/Core/GameEngine/Source/GameClient/SelectionInfo.cpp +++ b/Core/GameEngine/Source/GameClient/SelectionInfo.cpp @@ -29,7 +29,7 @@ #include "Common/ActionManager.h" // TheSuperHackers @feature for the EasyMilitaryDrag option -#include "Common/OptionPreferences.h" +#include "Common/GlobalData.h" #include "Common/ThingTemplate.h" #include "Common/PlayerList.h" #include "Common/Player.h" @@ -77,8 +77,9 @@ Bool isEasyMilitaryDragInvertedActive( Bool selectionIsPoint ) if (!TheKeyboard || !TheKeyboard->isCtrl()) return FALSE; - OptionPreferences optionPref; - return optionPref.getEasyMilitaryDragEnabled(); + // Read from the cached GlobalData copy: constructing OptionPreferences reparses + // Options.ini from disk, far too heavy for the input path. + return TheGlobalData && TheGlobalData->m_easyMilitaryDrag; } //------------------------------------------------------------------------------------------------- @@ -97,15 +98,12 @@ PickDrawableStruct::PickDrawableStruct() : drawableListToFill(nullptr), isPointS easyMilitaryDrag = FALSE; easyMilitaryDragInverted = FALSE; easyMilitaryDragDisabled = FALSE; + if (TheGlobalData && TheGlobalData->m_easyMilitaryDrag) { - OptionPreferences optionPref; - if (optionPref.getEasyMilitaryDragEnabled()) - { - if (TheKeyboard && TheKeyboard->isCtrl()) - easyMilitaryDragInverted = TRUE; - else - easyMilitaryDrag = TRUE; - } + if (TheKeyboard && TheKeyboard->isCtrl()) + easyMilitaryDragInverted = TRUE; + else + easyMilitaryDrag = TRUE; } // isPointSelection is set by the caller after construction, so this uses the flag resolved diff --git a/Generals/Code/GameEngine/Include/Common/GlobalData.h b/Generals/Code/GameEngine/Include/Common/GlobalData.h index 0c8e8820660..a7d71d210f9 100644 --- a/Generals/Code/GameEngine/Include/Common/GlobalData.h +++ b/Generals/Code/GameEngine/Include/Common/GlobalData.h @@ -141,6 +141,8 @@ class GlobalData : public SubsystemInterface Bool m_useAlternateMouse; Bool m_useRightMouseScrollWithAlternateMouse; // TheSuperHackers @feature User option for RMB scroll in Alternate Mouse mode. Bool m_clientRetaliationModeEnabled; + // TheSuperHackers @feature Leave builders out of drag selections. + Bool m_easyMilitaryDrag; Bool m_doubleClickAttackMove; Bool m_rightMouseAlwaysScrolls; Int m_jpegQuality; // TheSuperHackers @feature Quality for JPEG screenshots. diff --git a/Generals/Code/GameEngine/Source/Common/GlobalData.cpp b/Generals/Code/GameEngine/Source/Common/GlobalData.cpp index f7720c351a2..cc6b9580f76 100644 --- a/Generals/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/Generals/Code/GameEngine/Source/Common/GlobalData.cpp @@ -1047,6 +1047,7 @@ GlobalData::GlobalData() m_useRightMouseScrollWithAlternateMouse = TRUE; #endif m_clientRetaliationModeEnabled = TRUE; //On by default. + m_easyMilitaryDrag = FALSE; m_doubleClickAttackMove = FALSE; } @@ -1199,6 +1200,7 @@ void GlobalData::parseGameDataDefinition( INI* ini ) TheWritableGlobalData->m_useRightMouseScrollWithAlternateMouse = optionPref.getRightMouseScrollWithAlternateMouseEnabled(); TheWritableGlobalData->m_clientRetaliationModeEnabled = optionPref.getRetaliationModeEnabled(); TheWritableGlobalData->m_doubleClickAttackMove = optionPref.getDoubleClickAttackMoveEnabled(); + TheWritableGlobalData->m_easyMilitaryDrag = optionPref.getEasyMilitaryDragEnabled(); TheWritableGlobalData->m_jpegQuality = optionPref.getJpegQuality(); TheWritableGlobalData->m_keyboardScrollFactor = optionPref.getScrollFactor(); TheWritableGlobalData->m_drawScrollAnchor = optionPref.getDrawScrollAnchor(); diff --git a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h index 89a5fa08f9d..30bab02ef2f 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h @@ -142,6 +142,8 @@ class GlobalData : public SubsystemInterface Bool m_useAlternateMouse; Bool m_useRightMouseScrollWithAlternateMouse; // TheSuperHackers @feature User option for RMB scroll in Alternate Mouse mode. Bool m_clientRetaliationModeEnabled; + // TheSuperHackers @feature Leave builders out of drag selections. + Bool m_easyMilitaryDrag; Bool m_doubleClickAttackMove; Bool m_rightMouseAlwaysScrolls; Int m_jpegQuality; // TheSuperHackers @feature Quality for JPEG screenshots. diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp index e862cd149d5..6294ac511b8 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp @@ -1062,6 +1062,7 @@ GlobalData::GlobalData() m_useRightMouseScrollWithAlternateMouse = TRUE; #endif m_clientRetaliationModeEnabled = TRUE; //On by default. + m_easyMilitaryDrag = FALSE; m_doubleClickAttackMove = FALSE; } @@ -1206,6 +1207,7 @@ void GlobalData::parseGameDataDefinition( INI* ini ) TheWritableGlobalData->m_useRightMouseScrollWithAlternateMouse = optionPref.getRightMouseScrollWithAlternateMouseEnabled(); TheWritableGlobalData->m_clientRetaliationModeEnabled = optionPref.getRetaliationModeEnabled(); TheWritableGlobalData->m_doubleClickAttackMove = optionPref.getDoubleClickAttackMoveEnabled(); + TheWritableGlobalData->m_easyMilitaryDrag = optionPref.getEasyMilitaryDragEnabled(); TheWritableGlobalData->m_jpegQuality = optionPref.getJpegQuality(); TheWritableGlobalData->m_keyboardScrollFactor = optionPref.getScrollFactor(); TheWritableGlobalData->m_drawScrollAnchor = optionPref.getDrawScrollAnchor(); From 56db8760cc3bb8ede0f05fe99395202b77dc1f2f Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:20:49 +0300 Subject: [PATCH 3/3] fix(client): Exclude contained riders from the drag fallback test A rider in a non enclosing container such as the Overlord's bunker is a visible, locally controlled non structure, but the final selection loop discards contained objects - so it satisfied the fallback test while never surviving selection, suppressing the builders-only pass. Count only uncontained units. --- .../Source/GameClient/MessageStream/SelectionXlat.cpp | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Core/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp b/Core/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp index 6236e2e1579..4fe34f07ce0 100644 --- a/Core/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp +++ b/Core/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp @@ -759,12 +759,15 @@ GameMessageDisposition SelectionTranslator::onMouseLeftClick(MAYBE_UNUSED const for (DrawableListIt it = drawablesThatWillSelect.begin(); it != drawablesThatWillSelect.end(); ++it) { - // Only the player's own units count: a foreign drawable in the box is rejected by the - // ownership filtering downstream, so letting it satisfy this test would suppress the - // builders-only pass and leave the drag selecting nothing. + // Only units the final selection loop will actually keep count: a foreign drawable + // is rejected by the ownership filtering downstream, and a rider inside a container + // such as the Overlord's bunker is discarded as contained - letting either satisfy + // this test would suppress the builders-only pass and leave the drag selecting + // nothing. const Drawable *d = *it; if (d && !d->isKindOf(KINDOF_STRUCTURE) && - d->getObject() && d->getObject()->isLocallyControlled()) + d->getObject() && d->getObject()->isLocallyControlled() && + d->getObject()->getContainedBy() == nullptr) { anyNonStructure = TRUE; break;