Skip to content

feat(client): Add CastMode option for quick casting targeted commands - #3218

Open
triatomic wants to merge 2 commits into
TheSuperHackers:mainfrom
triatomic:qol/quick-cast
Open

feat(client): Add CastMode option for quick casting targeted commands#3218
triatomic wants to merge 2 commits into
TheSuperHackers:mainfrom
triatomic:qol/quick-cast

Conversation

@triatomic

Copy link
Copy Markdown

Adds an opt-in client option for how targeted commands (guard, attack move, abilities) are triggered from the keyboard. Defaults off; retail behavior unless set in Options.ini.

CastModeNormal (retail, default), QuickCast (hotkey fires immediately at the cursor), QuickCastWithIndicator (hold to aim with the targeting decal, release to fire).

Quick cast synthesizes the same arm-and-click message flow a manual cast produces, so the engine's validation, voice responses and cleanup all apply — the logic side sees exactly what a mouse click would send, so there is no determinism risk in multiplayer or replays. Commands that must not fire blind decline into the normal two-step flow: single-use commands, rally points, beacons, construction placement, and superweapons.

A cast requested while the ability is still recharging is queued and fires the moment the logic side reports ready, pinned to the world position it was aimed at. Right click cancels the queue; it expires after a minute rather than firing out of nowhere. A button disabled only by WIN_STATUS_NOT_READY lets the press through, so a still-firing weapon can be retargeted without a Stop first — matching the engine's own DragonTank firewall expectations documented in ControlBarCommand.cpp.

Zero Hour only per the contribution guidelines: the indicator and queue live in Zero Hour's InGameUI, and the shared translator/command-bar hooks are guarded with RTS_ZEROHOUR. Both titles compile. A Generals replica can follow after review.

This option ships in the Contra mod's engine fork and has been played there extensively. One adaptation to be transparent about: the fork resolves the indicator decal through its own custom radius plumbing, which is not ported — here the decal comes from the command's getRadiusCursorType() and the stock 3-arg setRadiusCursor, correct for retail content but not itself playtested. Code was written with LLM assistance and human-reviewed by the author.

Prepared for Squash and Merge.

Adds an Options.ini client preference for how targeted commands (guard,
attack move, abilities) are triggered from the keyboard:

  CastMode = Normal                  ; click button, then click world (retail, default)
  CastMode = QuickCast               ; hotkey fires immediately at the cursor
  CastMode = QuickCastWithIndicator  ; hold to aim with the targeting decal, release to fire

Quick cast synthesizes the same arm-and-click message flow a manual cast
produces, so the engine's validation, voice responses and cleanup all
apply and the logic side sees exactly what a mouse click would send - no
determinism risk. Commands that must not fire blind decline into the
normal two step flow: multi-click and single-use commands, rally points,
beacons, construction placement, and superweapons.

A cast requested while the ability is still recharging is queued and
fires the moment the logic side reports ready, pinned to the world
position it was aimed at; right click cancels it, and it expires rather
than firing minutes later. A button disabled only by WIN_STATUS_NOT_READY
lets the press through so a still-firing weapon can be retargeted without
a Stop, matching the engine's own DragonTank firewall expectations.

Zero Hour only, per the contribution guidelines: the indicator and queue
live in Zero Hour's InGameUI, and the shared translator and command bar
hooks are guarded with RTS_ZEROHOUR. Both titles compile. Adapted from
the Contra fork: the indicator resolves the decal from the command's own
radius cursor type (the fork's custom decal radius plumbing is not
ported).
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add configurable quick casting for targeted commands

✨ Enhancement ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Adds normal, immediate, and hold-to-aim casting modes for targeted command hotkeys.
• Queues recharging abilities at their aimed world position with cancellation and expiry.
• Reuses standard command messages while excluding unsafe placements and preserving Generals
 compatibility.
Diagram

graph TD
  A["Options.ini"] --> B["Global Settings"] --> C["Hotkey Translator"] --> D["Control Bar"] --> E["Message Stream"] --> F["Command Logic"]
  D --> G["UI Cast State"] --> E
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Invoke command helpers directly
  • ➕ Avoids synthesizing mouse-click messages
  • ➕ Could reduce UI message plumbing
  • ➖ Duplicates or bypasses existing validation and cleanup
  • ➖ Risks behavior divergence between manual and quick casts
  • ➖ Raises multiplayer and replay determinism concerns
2. Introduce a dedicated quick-cast game message
  • ➕ Makes quick-cast intent explicit
  • ➕ Could carry world coordinates directly
  • ➖ Expands logic-side and replay protocol scope
  • ➖ Requires duplicated compatibility and validation handling
  • ➖ Creates greater cross-title maintenance cost
3. Do not queue cooldown casts
  • ➕ Removes persistent UI state and per-frame readiness checks
  • ➕ Reduces cancellation and expiry edge cases
  • ➖ Drops player input during recharge
  • ➖ Does not support the intended quality-of-life behavior

Recommendation: Keep the arm-and-click synthesis because it converges on the established validation and command pipeline with the lowest determinism risk. The UI-owned queue is justified by the feature requirements, but its lifecycle, source-selection assumptions, indicator behavior, and both-title compilation deserve focused testing.

Files changed (10) +434 / -5

Enhancement (6) +394 / -5
HotKey.hExpose hotkey quick-cast execution state +13/-0

Expose hotkey quick-cast execution state

• Adds static state indicating keyboard-synthesized button presses and hold-to-aim key-down processing. The control bar uses these flags to distinguish hotkeys from mouse clicks.

Core/GameEngine/Include/GameClient/HotKey.h

ControlBarCommandProcessing.cppDispatch eligible targeted commands through quick cast +135/-0

Dispatch eligible targeted commands through quick cast

• Quick-casts hotkey-triggered targeted commands at the battlefield cursor through the normal arm-and-click message path. Unsafe command categories and replay playback fall back to normal aiming, while recharging special powers enter the UI queue.

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

HotKey.cppSupport immediate and hold-to-aim hotkey activation +61/-4

Support immediate and hold-to-aim hotkey activation

• Processes indicator mode on key down and key up, tracks synthesized hotkey execution, and allows buttons disabled only as not ready to reach quick-cast handling. Zero Hour guards preserve Generals behavior.

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

SelectionXlat.cppCancel queued quick casts with right click +13/-0

Cancel queued quick casts with right click

• Consumes right-click cancellation when a cooldown-bound quick cast is pending, matching cancellation behavior for normally armed commands.

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

InGameUI.hDeclare quick-cast indicator and queue state +19/-0

Declare quick-cast indicator and queue state

• Adds APIs and state for cast feedback, queued command targeting, source tracking, and timeout handling in Zero Hour's in-game UI.

GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h

InGameUI.cppRender indicators and execute queued casts +153/-1

Render indicators and execute queued casts

• Pins and fades command-specific targeting decals, stores queued casts at world coordinates, and checks special-power readiness each frame. Queues cancel when invalid, expire after one minute, or dispatch through the standard click message when ready.

GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp

Other (4) +40 / -0
OptionPreferences.hDefine CastMode values and preference accessor +14/-0

Define CastMode values and preference accessor

• Adds normal, immediate quick-cast, and indicator-assisted quick-cast modes with retail behavior as the default. Exposes the parsed client preference through OptionPreferences.

Core/GameEngine/Include/Common/OptionPreferences.h

OptionPreferences.cppParse CastMode from Options.ini +21/-0

Parse CastMode from Options.ini

• Accepts named or numeric CastMode values and falls back to normal casting for missing or invalid input.

Core/GameEngine/Source/Common/OptionPreferences.cpp

GlobalData.hStore the active client cast mode +3/-0

Store the active client cast mode

• Adds a global client setting for the parsed CastMode without coupling GlobalData to the preference header.

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

GlobalData.cppLoad CastMode into global client settings +2/-0

Load CastMode into global client settings

• Initializes CastMode to retail behavior and copies the Options.ini preference into writable global data during configuration parsing.

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

@greptile-apps

greptile-apps Bot commented Aug 26, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds configurable quick-cast and hold-to-aim behavior for Zero Hour targeted-command hotkeys, including cooldown queues and targeting indicators. The latest changes prevent non-targeted hotkeys from executing on both key transitions and retain queued targets in world space until they can be projected safely.

Confidence Score: 5/5

The PR appears safe to merge because both previously reported failures are addressed and no blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp Dispatches indicator-mode hotkeys on key-down and key-up while exposing the current transition to command processing.
Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommandProcessing.cpp Adds quick-cast dispatch and now suppresses the key-down execution of non-targeted commands in hold-to-aim mode.
GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp Implements quick-cast indicators and cooldown queues, retaining queued targets in world space and waiting for successful projection before firing.
Core/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp Extends right-click cancellation to pending quick casts.
Core/GameEngine/Source/Common/OptionPreferences.cpp Parses named and numeric CastMode values with retail behavior as the fallback.

Sequence Diagram

sequenceDiagram
    participant Player
    participant Hotkeys as HotKey translator
    participant ControlBar
    participant UI as InGameUI
    participant Stream as Message stream
    Player->>Hotkeys: Press targeted-command hotkey
    Hotkeys->>ControlBar: Select command
    alt Ability ready
        ControlBar->>UI: Arm command
        ControlBar->>Stream: Synthetic world click
    else Ability recharging
        ControlBar->>UI: Queue command and world target
        UI->>UI: Wait for readiness and successful projection
        UI->>Stream: Arm command and dispatch projected click
    end
Loading

Reviews (2): Last reviewed commit: "fix(client): Harden quick cast against d..." | Re-trigger Greptile

Comment thread Core/GameEngine/Source/GameClient/MessageStream/HotKey.cpp
Comment thread GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp Outdated
@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 (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Queued cast ignores selection ✓ Resolved 🐞 Bug ≡ Correctness
Description
updateQueuedQuickCast validates the saved source's life and ownership but never verifies that it
remains selected, then dispatches through the current UI selection. Changing selection while waiting
can therefore execute the queued command on different units once the old source becomes ready.
Code

GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[R1645-1648]

+	// the caster has to still exist, still be ours, and still be selected
+	Object *source = TheGameLogic->findObjectByID( m_queuedCastSourceID );
+	if( source == nullptr || source->isEffectivelyDead() ||
+			source->getControllingPlayer() != ThePlayerList->getLocalPlayer() )
Evidence
The queue records the first selected object's ID and checks readiness on that saved object, but the
synthetic command path resolves execution using the selection that exists when the click is
processed.

GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[1611-1613]
GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[1645-1665]
GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[1678-1687]
Core/GameEngine/Source/GameClient/MessageStream/GUICommandTranslator.cpp[361-364]
Core/GameEngine/Source/GameClient/MessageStream/GUICommandTranslator.cpp[400-409]

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

## Issue description
A queued cast is readiness-checked against its original source but eventually executes against the current selection, so changing selection can redirect the cast to different units.
## Issue Context
The source ID is captured when queuing. Before dispatch, verify that this exact object is still part of the active selection; otherwise cancel the queue.
## Fix Focus Areas
- GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[1611-1613]
- GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[1645-1651]
- GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[1678-1687]

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


2. Fallback redirects queued casts ✓ Resolved 🐞 Bug ≡ Correctness
Description
When the remembered target is outside the current camera frustum, worldToScreen returns false and
the code sends the original screen pixel instead. After the camera moves, the translator maps that
stale pixel through the current view, causing the queued cast to land at a different world position.
Code

GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[R1673-1676]

+	// aim at the remembered world position, so a scrolled camera does not move the target
+	ICoord2D reprojected;
+	if( TheTacticalView->worldToScreen( &m_queuedCastWorldPos, &reprojected ) )
+		screenPos = reprojected;
Evidence
The boolean projection succeeds only inside the current frustum, while command translation converts
the chosen pixel back to terrain using the current tactical view; retaining the original pixel
therefore does not retain the original world target.

GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[1602-1609]
GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[1670-1687]
Core/GameEngine/Include/GameClient/View.h[244-245]
Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp[2313-2326]
Core/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp[4067-4080]

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

## Issue description
The queued cast falls back to an old screen coordinate whenever its saved world target is off-screen, so camera movement changes the eventual target.
## Issue Context
`worldToScreen` only reports success for points inside the frustum. Do not reinterpret the original pixel under the new camera; either use the projected off-screen coordinate from the tri-state API or dispatch the remembered world coordinate through a path that preserves normal validation.
## Fix Focus Areas
- GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[1602-1609]
- GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[1670-1687]

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


3. Queued command becomes dangling ✓ Resolved 🐞 Bug ☼ Reliability
Description
The queue retains a raw CommandButton*, but changing display resolution recreates the control bar
and deletes its command buttons while the same InGameUI continues updating. A pending queue can
then dereference freed memory on the next update, causing undefined behavior or a crash.
Code

GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[R1602-1603]

+	m_queuedCastCommand = command;
+	m_queuedCastScreenPos = screenPos;
Evidence
The queue stores and later dereferences the unowned pointer. The live options flow recreates
TheControlBar, whose destructor deletes all command buttons, while InGameUI::update continues
polling the queue.

GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[1597-1603]
GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[1634-1655]
GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[1983-1987]
GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[6160-6169]
GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/OptionsMenu.cpp[851-879]
Core/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp[1026-1034]

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

## Issue description
Queued casts retain a pointer owned by the control bar, which can be deleted during control-bar recreation while the queue remains active.
## Issue Context
Replace the raw retained pointer with stable command data/identity that can be resolved against the current command set, or explicitly cancel queued casts before every control-bar destruction/recreation path. Ensure update never dereferences a button from a destroyed control bar.
## Fix Focus Areas
- GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[1597-1603]
- GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[1634-1655]
- GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[6160-6169]

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



Remediation recommended

4. Hint loses radius inputs ✓ Resolved 🐞 Bug ≡ Correctness
Description
The fading-hint redraw discards the command's special-power template and hardcodes PRIMARY_WEAPON,
although radius calculation depends on those values. Special-power hints disappear after the cursor
is cleared, and non-primary weapon hints can be redrawn with the wrong radius.
Code

GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[R3010-3013]

+    if ( --m_quickCastHintTimer > 0 )
+    {
+      setRadiusCursor( m_quickCastHintCursorType, nullptr, PRIMARY_WEAPON );
+      return;
Evidence
The initial hint passes the command's actual template and weapon slot, but restoration passes null
and the primary slot. The radius-cursor implementation reads those parameters to calculate
special-power and weapon radii and declines to create a cursor when the resulting radius is
non-positive.

GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[1458-1504]
GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[1508-1513]
GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[1585-1592]
GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[3004-3013]

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

## Issue description
Quick-cast hint restoration recreates the radius cursor without the special-power template and weapon slot used for its initial creation.
## Issue Context
Persist the relevant template and weapon slot with the quick-cast hint state, then pass those same values whenever the fading cursor is recreated.
## Fix Focus Areas
- GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h[760-763]
- GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[1585-1592]
- GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp[3008-3013]

ⓘ 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 GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp
Comment thread GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp Outdated
Comment thread GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp Outdated
Comment thread GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp
…angling state

Addresses the review findings:

- In hold to aim mode the key down pass executed every hotkey, not just
  the targeted commands it exists to arm - a production hotkey queued two
  units per press and a toggle undid itself. Non targeted commands now
  swallow the key down half and act on key up alone.

- A queued cast whose remembered target has scrolled out of the frustum
  no longer falls back to the originally captured pixel, which the
  current camera would resolve to different terrain. It holds until the
  spot is back on screen, or expires.

- A queued cast now requires its source to still be part of the current
  selection at fire time, since the synthesized click acts on the current
  selection - changing selection while waiting cancels rather than
  redirects.

- The queue remembers the command by name and re-resolves it through the
  control bar at fire time; the retained pointer could dangle when a
  resolution change recreates the control bar's buttons.

- The fading hint redraw recreates the radius cursor with the template
  and weapon slot it was made with, instead of nullptr and
  PRIMARY_WEAPON.
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