From a8af4725a9254bbe3e24cacf1f80a81742693ec5 Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:51:17 +0300 Subject: [PATCH 1/4] feat(client): Add GridHotkeys option keying command buttons by slot position Adds an Options.ini client preference assigning command bar hotkeys by slot position rather than by the letter the string file marked with an ampersand, so the keys stay in the same place whatever is being built: GridHotkeys = Yes ; off by default GridHotkeyLayout = QWERTYUIOASDFGHJKL ; key per slot, reading order GridHotkeyColumns = 9 ; bar width for the mapping The engine numbers command slots down each column rather than across each row, so the layout string is mapped through the column count. Slots past the end of the layout, and buttons outside the command bar, fall back to the string file letter. With grid hotkeys on, a visible command bar button that claims a key wins over a meta event bound to the same letter - but only for an unmodified (or Shift-batched) press, so Ctrl and Alt combos keep their meta events. Available in both Zero Hour and Generals: the consuming code lives in the shared ControlBar, HotKey and MetaEvent translators. --- .../Include/Common/OptionPreferences.h | 3 + .../Include/GameClient/ControlBar.h | 2 + Core/GameEngine/Include/GameClient/HotKey.h | 5 ++ .../Source/Common/OptionPreferences.cpp | 46 +++++++++++++ .../GameClient/GUI/ControlBar/ControlBar.cpp | 65 ++++++++++++++++++- .../GameClient/MessageStream/HotKey.cpp | 22 +++++++ .../GameClient/MessageStream/MetaEvent.cpp | 31 +++++++++ .../GameEngine/Include/Common/GlobalData.h | 4 ++ .../GameEngine/Source/Common/GlobalData.cpp | 6 ++ .../GameEngine/Include/Common/GlobalData.h | 4 ++ .../GameEngine/Source/Common/GlobalData.cpp | 6 ++ 11 files changed, 193 insertions(+), 1 deletion(-) diff --git a/Core/GameEngine/Include/Common/OptionPreferences.h b/Core/GameEngine/Include/Common/OptionPreferences.h index 85aba4228be..0789b2ea6c2 100644 --- a/Core/GameEngine/Include/Common/OptionPreferences.h +++ b/Core/GameEngine/Include/Common/OptionPreferences.h @@ -76,6 +76,9 @@ class OptionPreferences : public UserPreferences Real getScrollFactor(); Bool getDrawScrollAnchor(); Bool getMoveScrollAnchor(); + Bool getGridHotkeysEnabled() const; + AsciiString getGridHotkeyLayout() const; + Int getGridHotkeyColumns() const; Bool getCursorCaptureEnabledInWindowedGame() const; Bool getCursorCaptureEnabledInWindowedMenu() const; Bool getCursorCaptureEnabledInFullscreenGame() const; diff --git a/Core/GameEngine/Include/GameClient/ControlBar.h b/Core/GameEngine/Include/GameClient/ControlBar.h index 446277a13be..4206d1105e6 100644 --- a/Core/GameEngine/Include/GameClient/ControlBar.h +++ b/Core/GameEngine/Include/GameClient/ControlBar.h @@ -768,6 +768,8 @@ class ControlBar : public SubsystemInterface /// set the command data into the button void setControlCommand( GameWindow *button, const CommandButton *commandButton ); + // TheSuperHackers @feature Grid hotkey for a command bar slot, empty if not applicable. + AsciiString getGridHotKeyForButton( GameWindow *button ) const; void getForegroundMarkerPos(Int *x, Int *y); void getBackgroundMarkerPos(Int *x, Int *y); diff --git a/Core/GameEngine/Include/GameClient/HotKey.h b/Core/GameEngine/Include/GameClient/HotKey.h index 70206f24cf6..8cd1b998024 100644 --- a/Core/GameEngine/Include/GameClient/HotKey.h +++ b/Core/GameEngine/Include/GameClient/HotKey.h @@ -98,6 +98,11 @@ class HotKeyManager : public SubsystemInterface AsciiString searchHotKey( const AsciiString& label); AsciiString searchHotKey( const UnicodeString& uStr ); + // TheSuperHackers @feature Is this key currently claimed by a visible, enabled command + // button? Used so grid hotkeys can take precedence over a meta event bound to the same + // letter, but only while a button actually wants it. + Bool isHotKeyClaimed( const AsciiString& key ) const; + private: typedef std::map HotKeyMap; HotKeyMap m_hotKeyMap; diff --git a/Core/GameEngine/Source/Common/OptionPreferences.cpp b/Core/GameEngine/Source/Common/OptionPreferences.cpp index e681ef8b192..33518e8d709 100644 --- a/Core/GameEngine/Source/Common/OptionPreferences.cpp +++ b/Core/GameEngine/Source/Common/OptionPreferences.cpp @@ -216,6 +216,52 @@ Bool OptionPreferences::getRightMouseScrollWithAlternateMouseEnabled() const return FALSE; } +// TheSuperHackers @feature Grid hotkeys assign a command bar key by slot position rather than +// by whatever letter the string file marked with an ampersand. +// Options.ini: GridHotkeys = Yes +Bool OptionPreferences::getGridHotkeysEnabled() const +{ + OptionPreferences::const_iterator it = find("GridHotkeys"); + if (it == end()) + return FALSE; + + if (stricmp(it->second.str(), "yes") == 0) { + return TRUE; + } + return FALSE; +} + +// TheSuperHackers @feature The key for each slot, in slot order. Defaults to two rows of nine, +// matching a command bar that is two rows deep. Mods with a different bar can set their own, +// and the count is whatever the string is long -- slots past the end simply get no grid key. +// Options.ini: GridHotkeyLayout = QWERTYUIOASDFGHJKL +AsciiString OptionPreferences::getGridHotkeyLayout() const +{ + OptionPreferences::const_iterator it = find("GridHotkeyLayout"); + if (it == end() || it->second.isEmpty()) + return AsciiString("QWERTYUIOASDFGHJKL"); + + return it->second; +} + +// TheSuperHackers @feature How many columns the command bar is wide. The engine numbers command +// slots down each column rather than across each row, so the layout string -- which is written in +// reading order -- has to be mapped through this to land the right key on the right button. +// 0 means do not remap, i.e. the layout string is already in slot order. +// Options.ini: GridHotkeyColumns = 9 +Int OptionPreferences::getGridHotkeyColumns() const +{ + OptionPreferences::const_iterator it = find("GridHotkeyColumns"); + if (it == end()) + return 9; // two rows of nine, matching a bar two rows deep + + Int columns = atoi(it->second.str()); + if (columns < 0) + columns = 0; + + return columns; +} + Bool OptionPreferences::getRetaliationModeEnabled() { OptionPreferences::const_iterator it = find("Retaliation"); diff --git a/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp b/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp index 697ee47a0a4..fb2ac10933b 100644 --- a/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp +++ b/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp @@ -2456,6 +2456,57 @@ void ControlBar::setCommandBarBorder( GameWindow *button, CommandButtonMappedBor //------------------------------------------------------------------------------------------------- /** Set the command data into the control */ //------------------------------------------------------------------------------------------------- +// TheSuperHackers @feature Grid hotkeys. +//------------------------------------------------------------------------------------------------- +/** Which grid key belongs to this command bar button, or empty if it is not one of them. + * + * The slot is found by looking the window up in m_commandWindows, so the mapping follows the + * bar's own ordering and needs no assumption about how many slots a mod uses. Slots past the + * end of the configured layout simply get no grid key. */ +//------------------------------------------------------------------------------------------------- +AsciiString ControlBar::getGridHotKeyForButton( GameWindow *button ) const +{ + if( button == nullptr || TheGlobalData == nullptr ) + return AsciiString::TheEmptyString; + + const AsciiString &layout = TheGlobalData->m_gridHotkeyLayout; + if( layout.isEmpty() ) + return AsciiString::TheEmptyString; + + for( Int i = 0; i < MAX_COMMANDS_PER_SET; ++i ) + { + if( m_commandWindows[ i ] != button ) + continue; + + // The command bar numbers its slots down each column, but the layout string is written + // in reading order, so translate between the two. Without this, sequential letters land + // on alternating rows. + Int layoutIndex = i; + const Int columns = TheGlobalData->m_gridHotkeyColumns; + if( columns > 0 ) + { + // Rows come from the layout's own length, not MAX_COMMANDS_PER_SET, which is the + // internal cap of 32 rather than however many slots the mod actually shows. + const Int rows = ( layout.getLength() + columns - 1 ) / columns; + if( rows > 0 ) + { + const Int column = i / rows; // slots run down a column before moving right + const Int row = i % rows; + layoutIndex = row * columns + column; + } + } + + if( layoutIndex >= layout.getLength() ) + break; // this mod has more slots than the layout covers + + AsciiString key; + key.concat( layout.getCharAt( layoutIndex ) ); + return key; + } + + return AsciiString::TheEmptyString; +} + void ControlBar::setControlCommand( GameWindow *button, const CommandButton *commandButton ) { @@ -2527,7 +2578,19 @@ void ControlBar::setControlCommand( GameWindow *button, const CommandButton *com if (TheHotKeyManager) { - AsciiString hotKey = TheHotKeyManager->searchHotKey(commandButton->getTextLabel()); + // TheSuperHackers @feature Grid hotkeys key a button by where it sits in the command + // bar rather than by the letter its string file marked with an ampersand, so the keys + // stay in the same place whatever is being built. + AsciiString hotKey; + + if( TheGlobalData && TheGlobalData->m_gridHotkeysEnabled ) + hotKey = getGridHotKeyForButton( button ); + + // fall back to the string file letter, so a slot past the end of the layout, or a + // button that is not in the command bar at all, still keys the way it always did + if( hotKey.isEmpty() ) + hotKey = TheHotKeyManager->searchHotKey(commandButton->getTextLabel()); + if(hotKey.isNotEmpty()) TheHotKeyManager->addHotKey(button, hotKey); } diff --git a/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp b/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp index 0b7ecf22d1e..211ffd02e8f 100644 --- a/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp +++ b/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp @@ -157,6 +157,28 @@ void HotKeyManager::addHotKey( GameWindow *win, const AsciiString& keyIn) m_hotKeyMap[key] = newHK; } +// TheSuperHackers @feature See HotKey.h. +//----------------------------------------------------------------------------- +Bool HotKeyManager::isHotKeyClaimed( const AsciiString& keyIn ) const +{ + AsciiString key = keyIn; + key.toLower(); + + HotKeyMap::const_iterator it = m_hotKeyMap.find(key); + if( it == m_hotKeyMap.end() ) + return FALSE; + + GameWindow *win = it->second.m_win; + if( win == nullptr ) + return FALSE; + + // only a button the player can actually press counts as claiming the key + if( BitIsSet( win->winGetStatus(), WIN_STATUS_HIDDEN ) ) + return FALSE; + + return TRUE; +} + //----------------------------------------------------------------------------- Bool HotKeyManager::executeHotKey( const AsciiString& keyIn ) { diff --git a/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp b/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp index 31a954e67b5..b1a8119b80a 100644 --- a/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp +++ b/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp @@ -61,6 +61,11 @@ #include "GameClient/Keyboard.h" #endif +// TheSuperHackers @feature for grid hotkeys yielding to command bar buttons +#include "Common/GlobalData.h" +#include "GameClient/HotKey.h" +#include "GameClient/Keyboard.h" + MetaMap *TheMetaMap = nullptr; @@ -623,6 +628,32 @@ void MetaEventTranslator::onKeyPressed(GameMessageDisposition &disp, Int systemK } else { + // TheSuperHackers @feature With grid hotkeys on, a command bar button that has + // claimed this key wins over the meta event bound to the same letter. Checked + // here rather than by reordering translators, because the order is fixed before + // Options.ini is ever read. The meta event still fires whenever no button wants + // the key, so select all and friends keep working the rest of the time. + // Only ever yield for an unmodified key. A command bar hotkey is a bare letter, or + // Shift plus one for batching, so anything carrying Ctrl or Alt cannot be a cameo + // press and must keep its meta event. Without this a Ctrl combo whose base letter + // happens to sit on a visible button is swallowed: the meta event steps aside and + // HotKeyTranslator then rejects the modifiers, so the key does nothing at all. + if( (keyModState & ~SHIFT) == 0 && + TheGlobalData && TheGlobalData->m_gridHotkeysEnabled && TheHotKeyManager ) + { + WideChar wKey = TheKeyboard->getPrintableKey( (KeyDefType)keyType, 0 ); + UnicodeString uKey; + uKey.concat( wKey ); + AsciiString aKey; + aKey.translate( uKey ); + + if( aKey.isNotEmpty() && TheHotKeyManager->isHotKeyClaimed( aKey ) ) + { + disp = KEEP_MESSAGE; // let HotKeyTranslator have it + break; + } + } + // THIS IS A GREASY HACK... MESSAGE SHOULD BE HANDLED IN A TRANSLATOR, BUT DURING CINEMATICS THE TRANSLATOR IS DISABLED if( map->m_meta == GameMessage::MSG_META_TOGGLE_FAST_FORWARD_REPLAY) { diff --git a/Generals/Code/GameEngine/Include/Common/GlobalData.h b/Generals/Code/GameEngine/Include/Common/GlobalData.h index 0c8e8820660..15b5e3b9e33 100644 --- a/Generals/Code/GameEngine/Include/Common/GlobalData.h +++ b/Generals/Code/GameEngine/Include/Common/GlobalData.h @@ -142,6 +142,10 @@ class GlobalData : public SubsystemInterface Bool m_useRightMouseScrollWithAlternateMouse; // TheSuperHackers @feature User option for RMB scroll in Alternate Mouse mode. Bool m_clientRetaliationModeEnabled; Bool m_doubleClickAttackMove; + // TheSuperHackers @feature Command bar keys by slot position instead of by string file. + Bool m_gridHotkeysEnabled; + AsciiString m_gridHotkeyLayout; + Int m_gridHotkeyColumns; Bool m_rightMouseAlwaysScrolls; Int m_jpegQuality; // TheSuperHackers @feature Quality for JPEG screenshots. Bool m_useWaterPlane; diff --git a/Generals/Code/GameEngine/Source/Common/GlobalData.cpp b/Generals/Code/GameEngine/Source/Common/GlobalData.cpp index f7720c351a2..0ae576af8ec 100644 --- a/Generals/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/Generals/Code/GameEngine/Source/Common/GlobalData.cpp @@ -1048,6 +1048,9 @@ GlobalData::GlobalData() #endif m_clientRetaliationModeEnabled = TRUE; //On by default. m_doubleClickAttackMove = FALSE; + m_gridHotkeysEnabled = FALSE; + m_gridHotkeyLayout.clear(); + m_gridHotkeyColumns = 0; } @@ -1199,6 +1202,9 @@ void GlobalData::parseGameDataDefinition( INI* ini ) TheWritableGlobalData->m_useRightMouseScrollWithAlternateMouse = optionPref.getRightMouseScrollWithAlternateMouseEnabled(); TheWritableGlobalData->m_clientRetaliationModeEnabled = optionPref.getRetaliationModeEnabled(); TheWritableGlobalData->m_doubleClickAttackMove = optionPref.getDoubleClickAttackMoveEnabled(); + TheWritableGlobalData->m_gridHotkeysEnabled = optionPref.getGridHotkeysEnabled(); + TheWritableGlobalData->m_gridHotkeyLayout = optionPref.getGridHotkeyLayout(); + TheWritableGlobalData->m_gridHotkeyColumns = optionPref.getGridHotkeyColumns(); 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..ff2c09865ee 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h @@ -143,6 +143,10 @@ class GlobalData : public SubsystemInterface Bool m_useRightMouseScrollWithAlternateMouse; // TheSuperHackers @feature User option for RMB scroll in Alternate Mouse mode. Bool m_clientRetaliationModeEnabled; Bool m_doubleClickAttackMove; + // TheSuperHackers @feature Command bar keys by slot position instead of by string file. + Bool m_gridHotkeysEnabled; + AsciiString m_gridHotkeyLayout; + Int m_gridHotkeyColumns; Bool m_rightMouseAlwaysScrolls; Int m_jpegQuality; // TheSuperHackers @feature Quality for JPEG screenshots. Bool m_useWaterPlane; diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp index e862cd149d5..a5a37636fae 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp @@ -1063,6 +1063,9 @@ GlobalData::GlobalData() #endif m_clientRetaliationModeEnabled = TRUE; //On by default. m_doubleClickAttackMove = FALSE; + m_gridHotkeysEnabled = FALSE; + m_gridHotkeyLayout.clear(); + m_gridHotkeyColumns = 0; } @@ -1206,6 +1209,9 @@ void GlobalData::parseGameDataDefinition( INI* ini ) TheWritableGlobalData->m_useRightMouseScrollWithAlternateMouse = optionPref.getRightMouseScrollWithAlternateMouseEnabled(); TheWritableGlobalData->m_clientRetaliationModeEnabled = optionPref.getRetaliationModeEnabled(); TheWritableGlobalData->m_doubleClickAttackMove = optionPref.getDoubleClickAttackMoveEnabled(); + TheWritableGlobalData->m_gridHotkeysEnabled = optionPref.getGridHotkeysEnabled(); + TheWritableGlobalData->m_gridHotkeyLayout = optionPref.getGridHotkeyLayout(); + TheWritableGlobalData->m_gridHotkeyColumns = optionPref.getGridHotkeyColumns(); TheWritableGlobalData->m_jpegQuality = optionPref.getJpegQuality(); TheWritableGlobalData->m_keyboardScrollFactor = optionPref.getScrollFactor(); TheWritableGlobalData->m_drawScrollAnchor = optionPref.getDrawScrollAnchor(); From c8b54aac755f2de56ed8b81ba399df99456b6feb Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:53:48 +0300 Subject: [PATCH 2/4] feat(client): Add NonGridHotkeys to opt specific keys out of the grid Keys listed in Options.ini opt out of the grid entirely, so a mod can leave S, G and friends on their usual bindings while everything else grids: NonGridHotkeys = SG ; letters only, case insensitive, separators optional An excluded slot falls back to its string file letter, unless that letter collides with one the grid layout is still using - addHotKey keeps whichever button registered first and silently drops the other, so the excluded slot is left without a key rather than stealing one at random. An excluded key also keeps its meta event unconditionally, which is the point of listing it. Available in both Zero Hour and Generals, like GridHotkeys. --- .../Include/Common/OptionPreferences.h | 5 ++ .../Include/GameClient/ControlBar.h | 4 +- .../Source/Common/OptionPreferences.cpp | 42 +++++++++++++ .../GameClient/GUI/ControlBar/ControlBar.cpp | 63 +++++++++++++++++-- .../GameClient/MessageStream/MetaEvent.cpp | 9 ++- .../GameEngine/Include/Common/GlobalData.h | 2 + .../GameEngine/Source/Common/GlobalData.cpp | 2 + .../GameEngine/Include/Common/GlobalData.h | 2 + .../GameEngine/Source/Common/GlobalData.cpp | 2 + 9 files changed, 124 insertions(+), 7 deletions(-) diff --git a/Core/GameEngine/Include/Common/OptionPreferences.h b/Core/GameEngine/Include/Common/OptionPreferences.h index 0789b2ea6c2..c31809fb0fd 100644 --- a/Core/GameEngine/Include/Common/OptionPreferences.h +++ b/Core/GameEngine/Include/Common/OptionPreferences.h @@ -79,6 +79,11 @@ class OptionPreferences : public UserPreferences Bool getGridHotkeysEnabled() const; AsciiString getGridHotkeyLayout() const; Int getGridHotkeyColumns() const; + AsciiString getNonGridHotkeys() const; + Bool isNonGridHotkey(const AsciiString& key) const; + // TheSuperHackers @feature Exposed statically so GlobalData can test its cached copy of the + // list without re-reading Options.ini on every command bar rebuild. + static Bool isNonGridHotkeyInList(const AsciiString& list, const AsciiString& key); Bool getCursorCaptureEnabledInWindowedGame() const; Bool getCursorCaptureEnabledInWindowedMenu() const; Bool getCursorCaptureEnabledInFullscreenGame() const; diff --git a/Core/GameEngine/Include/GameClient/ControlBar.h b/Core/GameEngine/Include/GameClient/ControlBar.h index 4206d1105e6..39083bea671 100644 --- a/Core/GameEngine/Include/GameClient/ControlBar.h +++ b/Core/GameEngine/Include/GameClient/ControlBar.h @@ -769,7 +769,9 @@ class ControlBar : public SubsystemInterface /// set the command data into the button void setControlCommand( GameWindow *button, const CommandButton *commandButton ); // TheSuperHackers @feature Grid hotkey for a command bar slot, empty if not applicable. - AsciiString getGridHotKeyForButton( GameWindow *button ) const; + // isGridSlot, if given, reports whether the button is a command bar slot the grid covers, + // which is how the caller tells "excluded, so no key" apart from "not in the grid at all". + AsciiString getGridHotKeyForButton( GameWindow *button, Bool *isGridSlot = nullptr ) const; void getForegroundMarkerPos(Int *x, Int *y); void getBackgroundMarkerPos(Int *x, Int *y); diff --git a/Core/GameEngine/Source/Common/OptionPreferences.cpp b/Core/GameEngine/Source/Common/OptionPreferences.cpp index 33518e8d709..25e165e27ca 100644 --- a/Core/GameEngine/Source/Common/OptionPreferences.cpp +++ b/Core/GameEngine/Source/Common/OptionPreferences.cpp @@ -262,6 +262,48 @@ Int OptionPreferences::getGridHotkeyColumns() const return columns; } +// TheSuperHackers @feature Keys listed here opt out of the grid entirely. The slot that would +// have taken such a key falls back to its string file letter, and the key keeps whatever it +// normally does -- so a mod can leave S, G and friends on their usual bindings while everything +// else grids. Letters only, case insensitive, separators optional: "SG" and "S,G" both work. +// Options.ini: NonGridHotkeys = SG +AsciiString OptionPreferences::getNonGridHotkeys() const +{ + OptionPreferences::const_iterator it = find("NonGridHotkeys"); + if (it == end()) + return AsciiString::TheEmptyString; + + return it->second; +} + +// TheSuperHackers @feature Is this key one the player asked to keep out of the grid? +Bool OptionPreferences::isNonGridHotkey(const AsciiString& key) const +{ + if (key.isEmpty()) + return FALSE; + + return isNonGridHotkeyInList(getNonGridHotkeys(), key); +} + +// TheSuperHackers @feature Shared by the option lookup above and by GlobalData's cached copy, +// so both read the exclusion list exactly the same way. +Bool OptionPreferences::isNonGridHotkeyInList(const AsciiString& list, const AsciiString& key) +{ + if (list.isEmpty() || key.isEmpty()) + return FALSE; + + const char wanted = tolower(key.getCharAt(0)); + const char *c = list.str(); + for (; *c; ++c) + { + // a plain scan, so commas, spaces or nothing at all all work as separators + if (tolower(*c) == wanted) + return TRUE; + } + + return FALSE; +} + Bool OptionPreferences::getRetaliationModeEnabled() { OptionPreferences::const_iterator it = find("Retaliation"); diff --git a/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp b/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp index fb2ac10933b..7d8e7f92b96 100644 --- a/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp +++ b/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp @@ -40,6 +40,8 @@ #include "Common/GameType.h" #include "Common/MultiplayerSettings.h" #include "Common/NameKeyGenerator.h" +// TheSuperHackers @feature for the grid hotkey exclusion list +#include "Common/OptionPreferences.h" #include "Common/Override.h" #include "Common/PlayerTemplate.h" #include "Common/Player.h" @@ -2464,7 +2466,7 @@ void ControlBar::setCommandBarBorder( GameWindow *button, CommandButtonMappedBor * bar's own ordering and needs no assumption about how many slots a mod uses. Slots past the * end of the configured layout simply get no grid key. */ //------------------------------------------------------------------------------------------------- -AsciiString ControlBar::getGridHotKeyForButton( GameWindow *button ) const +AsciiString ControlBar::getGridHotKeyForButton( GameWindow *button, Bool *isGridSlot ) const { if( button == nullptr || TheGlobalData == nullptr ) return AsciiString::TheEmptyString; @@ -2499,8 +2501,21 @@ AsciiString ControlBar::getGridHotKeyForButton( GameWindow *button ) const if( layoutIndex >= layout.getLength() ) break; // this mod has more slots than the layout covers + // this slot belongs to the grid, whatever we decide its key is + if( isGridSlot != nullptr ) + *isGridSlot = TRUE; + AsciiString key; key.concat( layout.getCharAt( layoutIndex ) ); + + // A key on the exclusion list is not used for this slot, so the slot falls back to its + // string file letter -- which is the point of the exclusion list: the key is freed for + // whatever the player wants it for, and the button keys the way it did before grid + // hotkeys existed. isGridSlot stays set, so the caller knows to check for a collision + // before handing the slot a letter the grid is already using. + if( OptionPreferences::isNonGridHotkeyInList( TheGlobalData->m_nonGridHotkeys, key ) ) + return AsciiString::TheEmptyString; + return key; } @@ -2582,14 +2597,52 @@ void ControlBar::setControlCommand( GameWindow *button, const CommandButton *com // bar rather than by the letter its string file marked with an ampersand, so the keys // stay in the same place whatever is being built. AsciiString hotKey; + Bool isGridSlot = FALSE; if( TheGlobalData && TheGlobalData->m_gridHotkeysEnabled ) - hotKey = getGridHotKeyForButton( button ); + hotKey = getGridHotKeyForButton( button, &isGridSlot ); - // fall back to the string file letter, so a slot past the end of the layout, or a - // button that is not in the command bar at all, still keys the way it always did + // Fall back to the string file letter whenever the grid did not supply one. That covers a + // button the grid does not reach -- past the end of the layout, or not in the command bar + // at all -- and also a slot whose key the player put on NonGridHotkeys, which is what + // excluding a key is for: the key goes back to the game, and the button keys the way it + // did before grid hotkeys existed. if( hotKey.isEmpty() ) - hotKey = TheHotKeyManager->searchHotKey(commandButton->getTextLabel()); + { + const AsciiString strHotKey = TheHotKeyManager->searchHotKey(commandButton->getTextLabel()); + + // A string file letter that the grid layout also uses would be a straight collision: + // addHotKey keeps whichever slot registered first and silently drops the other, so one + // of the two buttons would end up with no key and no overlay letter, seemingly at + // random. Leave the excluded slot without a hotkey instead -- the grid keeps its own + // letters, which is the behaviour the fixed layout is there to guarantee. + // + // Tested against the layout rather than against what is currently registered, so the + // result does not depend on the order the slots happen to be populated in. + Bool collidesWithGrid = FALSE; + if( isGridSlot && strHotKey.isNotEmpty() && TheGlobalData ) + { + const AsciiString &layout = TheGlobalData->m_gridHotkeyLayout; + const char wanted = tolower( strHotKey.getCharAt( 0 ) ); + for( const char *c = layout.str(); *c; ++c ) + { + // A letter the player excluded is no longer the grid's, so it does not collide. + if( tolower( *c ) != wanted ) + continue; + + AsciiString gridKey; + gridKey.concat( *c ); + if( !OptionPreferences::isNonGridHotkeyInList( TheGlobalData->m_nonGridHotkeys, gridKey ) ) + { + collidesWithGrid = TRUE; + } + break; + } + } + + if( !collidesWithGrid ) + hotKey = strHotKey; + } if(hotKey.isNotEmpty()) TheHotKeyManager->addHotKey(button, hotKey); diff --git a/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp b/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp index b1a8119b80a..1ac046eabad 100644 --- a/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp +++ b/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp @@ -63,6 +63,7 @@ // TheSuperHackers @feature for grid hotkeys yielding to command bar buttons #include "Common/GlobalData.h" +#include "Common/OptionPreferences.h" #include "GameClient/HotKey.h" #include "GameClient/Keyboard.h" @@ -647,7 +648,13 @@ void MetaEventTranslator::onKeyPressed(GameMessageDisposition &disp, Int systemK AsciiString aKey; aKey.translate( uKey ); - if( aKey.isNotEmpty() && TheHotKeyManager->isHotKeyClaimed( aKey ) ) + // A key on the exclusion list keeps its meta event no matter what, which is the + // whole point of listing it -- otherwise a button that took the key through the + // string file fallback would still shadow it. + const Bool nonGrid = OptionPreferences::isNonGridHotkeyInList( + TheGlobalData->m_nonGridHotkeys, aKey ); + + if( !nonGrid && aKey.isNotEmpty() && TheHotKeyManager->isHotKeyClaimed( aKey ) ) { disp = KEEP_MESSAGE; // let HotKeyTranslator have it break; diff --git a/Generals/Code/GameEngine/Include/Common/GlobalData.h b/Generals/Code/GameEngine/Include/Common/GlobalData.h index 15b5e3b9e33..184ae8844a7 100644 --- a/Generals/Code/GameEngine/Include/Common/GlobalData.h +++ b/Generals/Code/GameEngine/Include/Common/GlobalData.h @@ -146,6 +146,8 @@ class GlobalData : public SubsystemInterface Bool m_gridHotkeysEnabled; AsciiString m_gridHotkeyLayout; Int m_gridHotkeyColumns; + // Keys the player asked to leave out of the grid; those slots keep their string file letter. + AsciiString m_nonGridHotkeys; Bool m_rightMouseAlwaysScrolls; Int m_jpegQuality; // TheSuperHackers @feature Quality for JPEG screenshots. Bool m_useWaterPlane; diff --git a/Generals/Code/GameEngine/Source/Common/GlobalData.cpp b/Generals/Code/GameEngine/Source/Common/GlobalData.cpp index 0ae576af8ec..2819ae09efb 100644 --- a/Generals/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/Generals/Code/GameEngine/Source/Common/GlobalData.cpp @@ -1051,6 +1051,7 @@ GlobalData::GlobalData() m_gridHotkeysEnabled = FALSE; m_gridHotkeyLayout.clear(); m_gridHotkeyColumns = 0; + m_nonGridHotkeys.clear(); } @@ -1205,6 +1206,7 @@ void GlobalData::parseGameDataDefinition( INI* ini ) TheWritableGlobalData->m_gridHotkeysEnabled = optionPref.getGridHotkeysEnabled(); TheWritableGlobalData->m_gridHotkeyLayout = optionPref.getGridHotkeyLayout(); TheWritableGlobalData->m_gridHotkeyColumns = optionPref.getGridHotkeyColumns(); + TheWritableGlobalData->m_nonGridHotkeys = optionPref.getNonGridHotkeys(); 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 ff2c09865ee..434626a745a 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h @@ -147,6 +147,8 @@ class GlobalData : public SubsystemInterface Bool m_gridHotkeysEnabled; AsciiString m_gridHotkeyLayout; Int m_gridHotkeyColumns; + // Keys the player asked to leave out of the grid; those slots keep their string file letter. + AsciiString m_nonGridHotkeys; Bool m_rightMouseAlwaysScrolls; Int m_jpegQuality; // TheSuperHackers @feature Quality for JPEG screenshots. Bool m_useWaterPlane; diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp index a5a37636fae..08ee7453859 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp @@ -1066,6 +1066,7 @@ GlobalData::GlobalData() m_gridHotkeysEnabled = FALSE; m_gridHotkeyLayout.clear(); m_gridHotkeyColumns = 0; + m_nonGridHotkeys.clear(); } @@ -1212,6 +1213,7 @@ void GlobalData::parseGameDataDefinition( INI* ini ) TheWritableGlobalData->m_gridHotkeysEnabled = optionPref.getGridHotkeysEnabled(); TheWritableGlobalData->m_gridHotkeyLayout = optionPref.getGridHotkeyLayout(); TheWritableGlobalData->m_gridHotkeyColumns = optionPref.getGridHotkeyColumns(); + TheWritableGlobalData->m_nonGridHotkeys = optionPref.getNonGridHotkeys(); TheWritableGlobalData->m_jpegQuality = optionPref.getJpegQuality(); TheWritableGlobalData->m_keyboardScrollFactor = optionPref.getScrollFactor(); TheWritableGlobalData->m_drawScrollAnchor = optionPref.getDrawScrollAnchor(); From 446b85df1c136599f9466418b02ff7e763688621 Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:15:30 +0300 Subject: [PATCH 3/4] fix: Allow Shift with command hotkeys so shift+hotkey batches production HotKeyTranslator bailed on any modifier, so holding Shift made command hotkeys do nothing at all. Shift+clicking a cameo queued five units, but shift plus the same button's hotkey queued none -- the keypress never reached the hotkey map. Shift now passes through. Ctrl and Alt still bail, since those carry their own bindings such as control groups, which command hotkeys must not shadow. The key lookup already asked for the unshifted character (getPrintableKey with state 0), so Shift+B still resolves to "b" and matches the lowercased hotkey map without further work. --- Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp b/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp index 211ffd02e8f..a59ac2997ec 100644 --- a/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp +++ b/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp @@ -96,7 +96,11 @@ GameMessageDisposition HotKeyTranslator::translateGameMessage(const GameMessage { newModState |= ALT; } - if(newModState != 0) + + // TheSuperHackers @feature Let Shift through so shift+hotkey batches production the same + // way shift+clicking the cameo does. Ctrl and Alt still bail, since those carry their own + // bindings (control groups and so on) that must not be shadowed by command hotkeys. + if( (newModState & ~SHIFT) != 0 ) return disp; WideChar key = TheKeyboard->getPrintableKey((KeyDefType)msg->getArgument(0)->integer, 0); UnicodeString uKey; From afd0926b65de202f76cc984b0aba7c416e89fdbb Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:15:38 +0300 Subject: [PATCH 4/4] fix(client): Support placeholder layout slots and document key claiming Addresses the review findings: - A non alphanumeric character in GridHotkeyLayout is now a placeholder: the slot is covered by the grid but gets no key. A layout shorter than a full bar keeps its row shape by padding with dots - the row count is derived from the layout's length, so a partial row would otherwise shift every key after it. - isHotKeyClaimed deliberately treats a visible but disabled button as claiming its key, now stated in code: the player pressing a greyed out cameo's key gets the disabled click feedback, rather than the press falling through to an unrelated meta event bound to the same letter. --- Core/GameEngine/Source/Common/OptionPreferences.cpp | 8 +++++--- .../Source/GameClient/GUI/ControlBar/ControlBar.cpp | 10 +++++++++- .../Source/GameClient/MessageStream/HotKey.cpp | 5 ++++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/Core/GameEngine/Source/Common/OptionPreferences.cpp b/Core/GameEngine/Source/Common/OptionPreferences.cpp index 25e165e27ca..0fc39e840e7 100644 --- a/Core/GameEngine/Source/Common/OptionPreferences.cpp +++ b/Core/GameEngine/Source/Common/OptionPreferences.cpp @@ -231,9 +231,11 @@ Bool OptionPreferences::getGridHotkeysEnabled() const return FALSE; } -// TheSuperHackers @feature The key for each slot, in slot order. Defaults to two rows of nine, -// matching a command bar that is two rows deep. Mods with a different bar can set their own, -// and the count is whatever the string is long -- slots past the end simply get no grid key. +// TheSuperHackers @feature The key for each slot, in reading order. Defaults to two rows of +// nine, matching a command bar that is two rows deep. Mods with a different bar can set their +// own; slots past the end of the layout simply get no grid key. The layout should cover whole +// rows -- pad any slot that should stay keyless with a non letter, e.g. "QWERT....", since the +// row shape is derived from the layout's length. // Options.ini: GridHotkeyLayout = QWERTYUIOASDFGHJKL AsciiString OptionPreferences::getGridHotkeyLayout() const { diff --git a/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp b/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp index 7d8e7f92b96..d1f82211299 100644 --- a/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp +++ b/Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp @@ -2505,8 +2505,16 @@ AsciiString ControlBar::getGridHotKeyForButton( GameWindow *button, Bool *isGrid if( isGridSlot != nullptr ) *isGridSlot = TRUE; + // A non alphanumeric layout character is a placeholder: the slot is covered by the + // grid but gets no key of its own. That lets a layout shorter than a full bar keep + // its row shape by padding with dots -- the row count above is derived from the + // layout's length, so a partial row would otherwise shift every key after it. + const char layoutChar = layout.getCharAt( layoutIndex ); + if( !isalnum( (unsigned char)layoutChar ) ) + return AsciiString::TheEmptyString; + AsciiString key; - key.concat( layout.getCharAt( layoutIndex ) ); + key.concat( layoutChar ); // A key on the exclusion list is not used for this slot, so the slot falls back to its // string file letter -- which is the point of the exclusion list: the key is freed for diff --git a/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp b/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp index a59ac2997ec..d6041f57c66 100644 --- a/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp +++ b/Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp @@ -176,7 +176,10 @@ Bool HotKeyManager::isHotKeyClaimed( const AsciiString& keyIn ) const if( win == nullptr ) return FALSE; - // only a button the player can actually press counts as claiming the key + // Only a visible button counts as claiming the key. A visible but currently disabled + // button claims it deliberately: the player pressing a greyed out cameo's key should get + // the disabled click feedback from executeHotKey, not have the press fall through to a + // meta event bound to the same letter and do something unrelated to the button they see. if( BitIsSet( win->winGetStatus(), WIN_STATUS_HIDDEN ) ) return FALSE;