Skip to content

feat(client): Add GridHotkeys and NonGridHotkeys options - #3215

Open
triatomic wants to merge 4 commits into
TheSuperHackers:mainfrom
triatomic:qol/grid-hotkeys
Open

feat(client): Add GridHotkeys and NonGridHotkeys options#3215
triatomic wants to merge 4 commits into
TheSuperHackers:mainfrom
triatomic:qol/grid-hotkeys

Conversation

@triatomic

Copy link
Copy Markdown

Adds two opt-in client options giving the command bar fixed positional hotkeys. Both default off; behavior is retail unless set in Options.ini.

GridHotkeys (= Yes) — keys each command bar button by its slot position rather than the letter the string file marked with an ampersand, so keys stay in the same place whatever is being built. GridHotkeyLayout (default QWERTYUIOASDFGHJKL) gives the key per slot in reading order; GridHotkeyColumns (default 9) maps the layout through the bar's column-major slot numbering. A visible button that claims a key wins over a meta event on the same letter — for unmodified (or Shift-batched) presses only, so Ctrl/Alt combos keep their meta events.

NonGridHotkeys (e.g. = SG) — keys listed here opt out of the grid; those slots fall back to their string-file letter (unless it collides with a letter the grid still uses, in which case the slot goes keyless rather than stealing at random), and the listed keys keep their normal meta events.

Works in both Zero Hour and Generals — the consuming code lives in the shared ControlBar/HotKey/MetaEvent translators, so both titles get it with their own GlobalData plumbing.

These options ship in the Contra mod's engine fork and have been played there; this PR is the port onto current main, adapted to the MetaEventTranslator refactor from #2758. Code was written with LLM assistance and human-reviewed, adapted and playtested by the author.

Prepared for Squash and Merge.

…osition

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.
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.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add configurable grid and non-grid command-bar hotkeys

✨ Enhancement ⚙️ Configuration changes 🕐 20-40 Minutes

Grey Divider

AI Description

• Adds opt-in positional command-bar hotkeys with configurable layout and column mapping.
• Supports excluded keys that retain legacy labels and meta-event behavior.
• Shares cached preferences and input precedence across Generals and Zero Hour.
Diagram

graph TD
  OPT["Options.ini"] -->|reads| PREF["Option Preferences"] -->|caches| GLOBAL["Game Global Data"] -->|configures| BAR["Control Bar"] -->|registers keys| HOTKEY["Hotkey Manager"]
  GLOBAL -->|enables policy| META["Meta Events"]
  INPUT["Keyboard Input"] -->|key press| META -->|yields claimed keys| HOTKEY
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Centralized hotkey policy resolver
  • ➕ Consolidates slot mapping, exclusions, collision checks, and precedence in one component.
  • ➕ Provides a cleaner unit-test boundary for complex key-resolution rules.
  • ➖ Requires a broader refactor across legacy translators and command-bar registration.
  • ➖ Increases integration risk for an opt-in compatibility feature shared by two titles.

Recommendation: Keep the PR's focused integration: caching preferences in existing GlobalData instances and resolving precedence at the established registration and translation points minimizes legacy-engine disruption. A centralized resolver would improve testability, but is better pursued separately if additional hotkey policies are added.

Files changed (11) +310 / -1

Enhancement (5) +186 / -1
ControlBar.hExpose command-slot grid key resolution +4/-0

Expose command-slot grid key resolution

• Declares the ControlBar helper that maps a command button to its configured positional key and distinguishes excluded grid slots from uncovered buttons.

Core/GameEngine/Include/GameClient/ControlBar.h

HotKey.hExpose visible hotkey claim detection +5/-0

Expose visible hotkey claim detection

• Adds a HotKeyManager query used to determine whether a currently visible command button owns a key.

Core/GameEngine/Include/GameClient/HotKey.h

ControlBar.cppAssign command-bar hotkeys by slot position +117/-1

Assign command-bar hotkeys by slot position

• Maps column-major command slots into the configured reading-order layout and excludes requested keys. Falls back to localized label hotkeys while deterministically suppressing collisions with active grid keys.

Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp

HotKey.cppDetect keys claimed by visible buttons +22/-0

Detect keys claimed by visible buttons

• Implements case-insensitive hotkey lookup and only treats non-hidden command windows as active claims for event precedence.

Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp

MetaEvent.cppYield eligible meta events to grid buttons +38/-0

Yield eligible meta events to grid buttons

• Lets visible command buttons take precedence for bare or Shift-modified grid keys. Preserves Ctrl/Alt combinations and all excluded-key meta events.

Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp

Other (6) +124 / -0
OptionPreferences.hExpose grid hotkey preference accessors +8/-0

Expose grid hotkey preference accessors

• Declares accessors for enabling grid hotkeys, configuring layout and columns, and listing excluded keys. Adds a static matcher so cached exclusion lists can be checked without rereading Options.ini.

Core/GameEngine/Include/Common/OptionPreferences.h

OptionPreferences.cppParse grid and exclusion hotkey options +88/-0

Parse grid and exclusion hotkey options

• Implements opt-in GridHotkeys parsing with default layout and column values. Parses NonGridHotkeys and provides case-insensitive matching with optional separators.

Core/GameEngine/Source/Common/OptionPreferences.cpp

GlobalData.hStore Generals grid hotkey settings +6/-0

Store Generals grid hotkey settings

• Adds cached Generals client state for grid enablement, layout, column count, and non-grid exclusions.

Generals/Code/GameEngine/Include/Common/GlobalData.h

GlobalData.cppLoad Generals grid hotkey preferences +8/-0

Load Generals grid hotkey preferences

• Initializes safe disabled defaults and copies parsed Options.ini grid settings into Generals global client data.

Generals/Code/GameEngine/Source/Common/GlobalData.cpp

GlobalData.hStore Zero Hour grid hotkey settings +6/-0

Store Zero Hour grid hotkey settings

• Adds cached Zero Hour client state for grid enablement, layout, column count, and non-grid exclusions.

GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h

GlobalData.cppLoad Zero Hour grid hotkey preferences +8/-0

Load Zero Hour grid hotkey preferences

• Initializes safe disabled defaults and copies parsed Options.ini grid settings into Zero Hour global client data.

GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 26, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Shift grid presses fail ✓ Resolved 🐞 Bug ≡ Correctness
Description
Grid hotkeys are registered as bare letters, but HotKeyTranslator rejects every key-up carrying
SHIFT, so Shift-batched grid commands never execute. For a claimed Shift+O/Shift+P meta binding,
the new yield condition also suppresses the meta event before the hotkey translator rejects the key,
leaving the press with no action.
Code

Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp[R642-643]

+				if( (keyModState & ~SHIFT) == 0 &&
+						TheGlobalData && TheGlobalData->m_gridHotkeysEnabled && TheHotKeyManager )
Evidence
The new condition explicitly yields Shift-only matches. MetaEventTranslator runs before
HotKeyTranslator, which reconstructs SHIFT and returns before executeHotKey; built-in Shift+O
and Shift+P records demonstrate paths where the original meta action is also suppressed.

Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp[612-623]
Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp[642-660]
Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp[925-953]
Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp[75-106]
Generals/Code/GameEngine/Source/GameClient/GameClient.cpp[288-292]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Shift-modified grid hotkeys are yielded as command-bar input, but `HotKeyTranslator` rejects all nonzero modifiers, so batching does not execute and colliding Shift meta events are swallowed.
## Issue Context
Allow Shift-only command hotkeys to reach `executeHotKey`, while continuing to reject Ctrl/Alt combinations and preserving correct meta-event disposition.
## Fix Focus Areas
- Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp[642-660]
- Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp[75-106]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Disabled buttons swallow meta keys 🐞 Bug ≡ Correctness
Description
isHotKeyClaimed returns true for a visible but disabled command button, causing
MetaEventTranslator to suppress the matching meta event. executeHotKey then refuses to activate
that disabled window, so the key performs neither action.
Code

Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp[R175-179]

+	// only a button the player can actually press counts as claiming the key
+	if( BitIsSet( win->winGetStatus(), WIN_STATUS_HIDDEN ) )
+		return FALSE;
+
+	return TRUE;
Evidence
The new helper checks hidden status only, although command-bar code routinely disables visible
buttons. The execution path separately requires WIN_STATUS_ENABLED, proving that yielding to such
a claimant consumes the key without invoking the command.

Core/GameEngine/Include/GameClient/HotKey.h[101-104]
Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp[171-179]
Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp[183-215]
Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp[657-660]
Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommand.cpp[202-202]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Visible disabled buttons are treated as active hotkey claimants, causing matching meta events to be yielded even though the button cannot execute.
## Issue Context
Match `isHotKeyClaimed` to `executeHotKey` by requiring both visible and enabled status before suppressing a meta event.
## Fix Focus Areas
- Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp[171-179]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Short layouts lose keys 🐞 Bug ≡ Correctness
Description
The remapping derives the row count from layout.length / columns, so a valid layout shorter than
GridHotkeyColumns produces one row and maps every slot after slot zero beyond the string. For
example, layout QWER with the default nine columns assigns only Q, contradicting the stated
behavior that only slots past the layout end should be keyless.
Code

Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp[R2490-2492]

+			// 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;
Evidence
Positive column values are accepted unchanged, and the new mapper uses the layout length to
calculate rows before rejecting out-of-range indices. The fallback path then gives these intended
grid slots their string-file hotkeys instead.

Core/GameEngine/Source/Common/OptionPreferences.cpp[234-262]
Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp[2486-2502]
Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp[2605-2612]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Short custom layouts are treated as changing the bar's row count, causing covered prefix slots to map out of bounds instead of receiving their configured keys.
## Issue Context
The layout length controls how many reading-order slots have keys; it should not redefine the command bar's physical row count. Preserve the documented `columns = 0` slot-order behavior.
## Fix Focus Areas
- Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp[2483-2502]
- Core/GameEngine/Source/Common/OptionPreferences.cpp[234-262]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can start a comment with 'qodo' or '@qodo' to chat about any finding

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp
Comment thread Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp Outdated
Comment on lines +2490 to +2492
// 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Short layouts lose keys 🐞 Bug ≡ Correctness

The remapping derives the row count from layout.length / columns, so a valid layout shorter than
GridHotkeyColumns produces one row and maps every slot after slot zero beyond the string. For
example, layout QWER with the default nine columns assigns only Q, contradicting the stated
behavior that only slots past the layout end should be keyless.
Agent Prompt
## Issue description
Short custom layouts are treated as changing the bar's row count, causing covered prefix slots to map out of bounds instead of receiving their configured keys.

## Issue Context
The layout length controls how many reading-order slots have keys; it should not redefine the command bar's physical row count. Preserve the documented `columns = 0` slot-order behavior.

## Fix Focus Areas
- Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp[2483-2502]
- Core/GameEngine/Source/Common/OptionPreferences.cpp[234-262]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in afd0926: a non-alphanumeric layout character is now a placeholder - the slot is covered by the grid but keyless - so a short layout keeps its row shape by padding (e.g. QWERT....). The option's documentation now states the whole-rows contract, since the row count is derived from the layout's length.

@greptile-apps

greptile-apps Bot commented Aug 26, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds opt-in positional command-bar hotkeys, configurable layout and exclusions, and consistent option plumbing for both game variants.

  • Maps command-bar slots to configurable grid keys while retaining string-defined keys for excluded slots.
  • Gives eligible command-bar hotkeys precedence over conflicting unmodified or Shift-modified meta events.
  • Stores the new client options in both Generals and Zero Hour global data.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
Core/GameEngine/Source/Common/OptionPreferences.cpp Parses grid-hotkey enablement, layout, column count, and case-insensitive exclusion options with documented defaults.
Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp Maps command-bar slots to positional keys and applies string-key fallback and collision handling for excluded slots.
Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp Allows Shift-only command hotkeys and exposes whether a visible command button currently claims a key.
Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp Routes eligible claimed keys to command-bar handling while preserving Ctrl, Alt, and excluded-key meta bindings.
Generals/Code/GameEngine/Source/Common/GlobalData.cpp Initializes and loads the new client options for Generals.
GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp Initializes and loads the same client options for Zero Hour.

Sequence Diagram

sequenceDiagram
    participant Input as Keyboard input
    participant Meta as MetaEventTranslator
    participant Hotkey as HotKeyTranslator
    participant Bar as Command bar
    Input->>Meta: Key press
    Meta->>Meta: Check modifiers, exclusions, and claimed key
    alt Eligible grid button claims key
        Meta-->>Hotkey: Keep message for hotkey handling
        Hotkey->>Bar: Send GBM_SELECTED
    else Meta event retains key
        Meta->>Meta: Execute mapped meta event
    end
Loading

Reviews (2): Last reviewed commit: "fix(client): Support placeholder layout ..." | Re-trigger Greptile

Comment on lines +175 to +179
// only a button the player can actually press counts as claiming the key
if( BitIsSet( win->winGetStatus(), WIN_STATUS_HIDDEN ) )
return FALSE;

return TRUE;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Disabled buttons claim keys

When an unmodified meta-event key overlaps a visible but disabled grid button, isHotKeyClaimed suppresses the meta event even though executeHotKey rejects the button, causing the key to perform no action beyond the disabled-click sound.

Suggested change
// only a button the player can actually press counts as claiming the key
if( BitIsSet( win->winGetStatus(), WIN_STATUS_HIDDEN ) )
return FALSE;
return TRUE;
// only a button the player can actually press counts as claiming the key
if( BitIsSet( win->winGetStatus(), WIN_STATUS_HIDDEN ) ||
!BitIsSet( win->winGetStatus(), WIN_STATUS_ENABLED ) )
return FALSE;
return TRUE;

Knowledge Base Used: Game client runtime

Prompt To Fix With AI
This is a comment left during a code review.
Path: Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp
Line: 175-179

Comment:
**Disabled buttons claim keys**

When an unmodified meta-event key overlaps a visible but disabled grid button, `isHotKeyClaimed` suppresses the meta event even though `executeHotKey` rejects the button, causing the key to perform no action beyond the disabled-click sound.

```suggestion
	// only a button the player can actually press counts as claiming the key
	if( BitIsSet( win->winGetStatus(), WIN_STATUS_HIDDEN ) ||
			!BitIsSet( win->winGetStatus(), WIN_STATUS_ENABLED ) )
		return FALSE;

	return TRUE;
```

**Knowledge Base Used:** [Game client runtime](https://app.greptile.com/thesuperhackers/-/custom-context/knowledge-base/thesuperhackers/generalsgamecode/-/docs/game-client-runtime.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deliberate, and now documented in code (afd0926): a visible but disabled button keeps claiming its key on purpose. executeHotKey gives the disabled-click feedback for it, which is the response the player expects when pressing the key of a greyed-out cameo they can see - having the press fall through to an unrelated meta event bound to the same letter would turn every "can't afford it yet" press into a misinput. The suggested change is intentionally not taken.

Comment thread Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant