From 9958e7097581dd4b08c387d4cb33666a47a87ce3 Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:03:22 +0300 Subject: [PATCH 1/5] feat: Add HealthBarDisplayMode option for modern health bar display Adds an Options.ini client preference controlling when health bars appear: HealthBarDisplayMode = Classic ; selected and moused over only (default) HealthBarDisplayMode = Damaged ; the above, plus anything below full health HealthBarDisplayMode = Always ; the above, plus all undamaged units/structures A plain index (0, 1, 2) is also accepted. Default is Classic, so behavior is unchanged unless the option is set. The mode is read into GlobalData alongside the other Options.ini overrides, so it refreshes whenever game data is parsed rather than only at process start. Deliberately a separate setting rather than an extension of m_showObjectHealth, which stays the master switch and remains bound to the existing CHEAT_SHOW_HEALTH and DEMO_SHOW_HEALTH keys. Bars are suppressed for corpses, projectiles, shrubbery, trees, mines, inert, unattackable, drawable only and non selectable objects, so the wider modes do not label scenery. Always mode is further limited to structures and objects with an AI module, i.e. real combatants and buildings. Hidden, stealthed and shrouded objects are already filtered upstream in drawablePostDraw, so no mode can reveal something the local player cannot see. Entirely client side: the new code only reads state and draws, so it cannot affect game logic, multiplayer sync or replays. --- .../Include/Common/OptionPreferences.h | 13 +++++ .../Source/Common/OptionPreferences.cpp | 23 ++++++++ .../GameEngine/Include/Common/GlobalData.h | 4 ++ .../GameEngine/Source/Common/GlobalData.cpp | 3 ++ .../GameEngine/Source/GameClient/Drawable.cpp | 52 +++++++++++++++++-- 5 files changed, 92 insertions(+), 3 deletions(-) diff --git a/Core/GameEngine/Include/Common/OptionPreferences.h b/Core/GameEngine/Include/Common/OptionPreferences.h index 85aba4228be..01b2534dac0 100644 --- a/Core/GameEngine/Include/Common/OptionPreferences.h +++ b/Core/GameEngine/Include/Common/OptionPreferences.h @@ -38,6 +38,18 @@ typedef UnsignedInt CursorCaptureMode; typedef UnsignedInt ScreenEdgeScrollMode; +// TheSuperHackers @feature When health bars are shown above objects. Purely a client side +// display preference -- it never affects game logic, so it is safe in multiplayer and replays. +enum HealthBarDisplayMode CPP_11(: Int) +{ + HealthBarDisplayMode_Classic = 0, ///< selected and moused over objects only (retail behavior) + HealthBarDisplayMode_Damaged, ///< the above, plus anything that is not at full health + HealthBarDisplayMode_Always, ///< the above, plus every undamaged unit and structure + + HealthBarDisplayMode_Count, + HealthBarDisplayMode_Default = HealthBarDisplayMode_Classic +}; + //----------------------------------------------------------------------------- // OptionsPreferences options menu class //----------------------------------------------------------------------------- @@ -71,6 +83,7 @@ class OptionPreferences : public UserPreferences Bool getAlternateMouseModeEnabled(); Bool getRightMouseScrollWithAlternateMouseEnabled() const; Bool getRetaliationModeEnabled(); + HealthBarDisplayMode getHealthBarDisplayMode() const; Bool getDoubleClickAttackMoveEnabled(); Int getJpegQuality() const; Real getScrollFactor(); diff --git a/Core/GameEngine/Source/Common/OptionPreferences.cpp b/Core/GameEngine/Source/Common/OptionPreferences.cpp index e681ef8b192..47ec48e2562 100644 --- a/Core/GameEngine/Source/Common/OptionPreferences.cpp +++ b/Core/GameEngine/Source/Common/OptionPreferences.cpp @@ -216,6 +216,29 @@ Bool OptionPreferences::getRightMouseScrollWithAlternateMouseEnabled() const return FALSE; } +// TheSuperHackers @feature Health bar display mode, read from Options.ini as +// HealthBarDisplayMode = Classic | Damaged | Always (a plain index also works). +HealthBarDisplayMode OptionPreferences::getHealthBarDisplayMode() const +{ + OptionPreferences::const_iterator it = find("HealthBarDisplayMode"); + if (it == end()) + return HealthBarDisplayMode_Default; + + if (stricmp(it->second.str(), "Always") == 0) + return HealthBarDisplayMode_Always; + if (stricmp(it->second.str(), "Damaged") == 0) + return HealthBarDisplayMode_Damaged; + if (stricmp(it->second.str(), "Classic") == 0) + return HealthBarDisplayMode_Classic; + + // also accept the raw index, so the value round trips if it is ever written numerically + Int mode = atoi(it->second.str()); + if (mode >= 0 && mode < HealthBarDisplayMode_Count) + return (HealthBarDisplayMode)mode; + + return HealthBarDisplayMode_Default; +} + Bool OptionPreferences::getRetaliationModeEnabled() { OptionPreferences::const_iterator it = find("Retaliation"); diff --git a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h index 89a5fa08f9d..e386728d2b3 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h @@ -142,6 +142,10 @@ 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 Client side health bar display preference, from Options.ini. + // Holds a HealthBarDisplayMode; stored as Int so this widely included header does not + // have to pull in OptionPreferences.h. + Int m_healthBarDisplayMode; 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..72001289446 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp @@ -1063,6 +1063,8 @@ GlobalData::GlobalData() #endif m_clientRetaliationModeEnabled = TRUE; //On by default. m_doubleClickAttackMove = FALSE; + // TheSuperHackers @feature Health bars behave exactly as they always have unless Options.ini says otherwise. + m_healthBarDisplayMode = HealthBarDisplayMode_Default; } @@ -1205,6 +1207,7 @@ void GlobalData::parseGameDataDefinition( INI* ini ) TheWritableGlobalData->m_useAlternateMouse = optionPref.getAlternateMouseModeEnabled(); TheWritableGlobalData->m_useRightMouseScrollWithAlternateMouse = optionPref.getRightMouseScrollWithAlternateMouseEnabled(); TheWritableGlobalData->m_clientRetaliationModeEnabled = optionPref.getRetaliationModeEnabled(); + TheWritableGlobalData->m_healthBarDisplayMode = optionPref.getHealthBarDisplayMode(); TheWritableGlobalData->m_doubleClickAttackMove = optionPref.getDoubleClickAttackMoveEnabled(); TheWritableGlobalData->m_jpegQuality = optionPref.getJpegQuality(); TheWritableGlobalData->m_keyboardScrollFactor = optionPref.getScrollFactor(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp index 54dff3325f2..6ae0e9e8837 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp @@ -42,6 +42,7 @@ #include "Common/GameState.h" #include "Common/GameUtility.h" #include "Common/GlobalData.h" +#include "Common/OptionPreferences.h" #include "Common/ModuleFactory.h" #include "Common/PerfTimer.h" #include "Common/Player.h" @@ -3764,6 +3765,42 @@ void Drawable::drawVeterancy( const IRegion2D *healthBarRegion ) // ------------------------------------------------------------------------------------------------ /** Draw health bar information for drawable */ // ------------------------------------------------------------------------------------------------ +// TheSuperHackers @feature Health bar display modes (Options.ini: HealthBarDisplayMode). +/** + * Should this object show a health bar even though it is neither selected nor moused over? + * + * Purely a client side display question -- it reads state but never changes any, so it cannot + * affect game logic, multiplayer sync or replays. + */ +static Bool isHealthBarAlwaysVisible( const Object *obj, Real healthRatio ) +{ + const Int mode = TheGlobalData->m_healthBarDisplayMode; + + if( mode == HealthBarDisplayMode_Classic ) + return FALSE; + + // never label a corpse or a piece of scenery, in any mode + if( obj->isEffectivelyDead() ) + return FALSE; + + if( obj->isKindOf( KINDOF_PROJECTILE ) + || obj->isKindOf( KINDOF_SHRUBBERY ) + || obj->isKindOf( KINDOF_OPTIMIZED_TREE ) + || obj->isKindOf( KINDOF_DRAWABLE_ONLY ) + || obj->isKindOf( KINDOF_INERT ) + || obj->isKindOf( KINDOF_UNATTACKABLE ) + || obj->isKindOf( KINDOF_MINE ) + || obj->isKindOf( KINDOF_NO_SELECT ) ) + return FALSE; + + if( mode == HealthBarDisplayMode_Damaged ) + return healthRatio < 1.0f; + + // HealthBarDisplayMode_Always -- real combatants and buildings only, so the map does not + // fill up with bars over rocks, crates and civilian props. + return obj->isKindOf( KINDOF_STRUCTURE ) || obj->getAI() != nullptr; +} + void Drawable::drawHealthBar(const IRegion2D* healthBarRegion) { if (!healthBarRegion) @@ -3771,11 +3808,14 @@ void Drawable::drawHealthBar(const IRegion2D* healthBarRegion) // // only draw health for selected drawables and drawables that have been moused over - // by the cursor + // by the cursor. TheSuperHackers @feature Options.ini HealthBarDisplayMode can widen this + // to also cover damaged objects, or every unit and structure. // - if( TheGlobalData->m_showObjectHealth && - (isSelected() || (TheInGameUI && (TheInGameUI->getMousedOverDrawableID() == getID()))) ) + if( TheGlobalData->m_showObjectHealth ) { + const Bool classicallyVisible = isSelected() + || (TheInGameUI && (TheInGameUI->getMousedOverDrawableID() == getID())); + Object *obj = getObject(); // if no object, nothing to do @@ -3805,6 +3845,12 @@ void Drawable::drawHealthBar(const IRegion2D* healthBarRegion) // what is our health ratio Real healthRatio = health / maxHealth; + // TheSuperHackers @feature Decide whether this object gets a bar it would not classically get. + // Objects hidden, stealthed or shrouded never reach here at all -- drawablePostDraw filters + // those out before drawIconUI is ever called. + if( !classicallyVisible && !isHealthBarAlwaysVisible( obj, healthRatio ) ) + return; + // // what color will we use for the health bar based on our ratio, this makes it // slowly go from green to red, (or from blue to cyan if under construction, or disabled) From 070b4fb10cc7bf4bf6854109caa287618f87b4fc Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:04:55 +0300 Subject: [PATCH 2/5] feat: Cycle the health bar display mode with Ctrl+` HealthBarDisplayMode could only be changed by editing Options.ini and restarting. This cycles it in game -- Classic, Damaged, Always -- with an on screen line naming the mode it landed on. Bound through generateMetaMap rather than requiring a CommandMap.ini entry, the same way pause, fast forward and the fps controls default themselves. The binding only installs if the message is still unbound, so a mod that maps CYCLE_HEALTH_BAR_MODE itself still wins. This matters here because Contra ships its command map inside a .big, where a loose override file would replace every binding rather than merge. Ctrl+` is unbound in retail and in both the debug and demo command maps. The tick key is layout dependent -- it prints something other than ` outside a US keyboard -- but the binding is by scancode, so the same physical key works everywhere. Purely a client side display setting. Nothing reaches the simulation, so it is safe in multiplayer and replays. The value is not written back to Options.ini, so it lasts for the session and the configured mode returns on the next load. --- .../GameEngine/Include/Common/MessageStream.h | 1 + .../Source/Common/MessageStream.cpp | 1 + .../GameClient/MessageStream/CommandXlat.cpp | 40 +++++++++++++++++++ .../GameClient/MessageStream/MetaEvent.cpp | 18 +++++++++ 4 files changed, 60 insertions(+) diff --git a/Core/GameEngine/Include/Common/MessageStream.h b/Core/GameEngine/Include/Common/MessageStream.h index e5dc1fc0896..a26ef34c01b 100644 --- a/Core/GameEngine/Include/Common/MessageStream.h +++ b/Core/GameEngine/Include/Common/MessageStream.h @@ -244,6 +244,7 @@ class GameMessage : public MemoryPoolObject MSG_META_TOGGLE_LOWER_DETAILS, ///< toggles graphics options to crappy mode instantly MSG_META_TOGGLE_CONTROL_BAR, ///< show/hide controlbar MSG_META_TOGGLE_PLAYER_OBSERVER, ///< TheSuperHackers @feature Toggle the player observer view in game + MSG_META_CYCLE_HEALTH_BAR_MODE, ///< TheSuperHackers @feature Cycle HealthBarDisplayMode in game MSG_META_BEGIN_PATH_BUILD, ///< enter path-building mode MSG_META_END_PATH_BUILD, ///< exit path-building mode diff --git a/Core/GameEngine/Source/Common/MessageStream.cpp b/Core/GameEngine/Source/Common/MessageStream.cpp index 9980021c6ee..c471db5b267 100644 --- a/Core/GameEngine/Source/Common/MessageStream.cpp +++ b/Core/GameEngine/Source/Common/MessageStream.cpp @@ -326,6 +326,7 @@ const char *GameMessage::getCommandTypeAsString(GameMessage::Type t) CASE_LABEL(MSG_META_TOGGLE_LOWER_DETAILS) CASE_LABEL(MSG_META_TOGGLE_CONTROL_BAR) CASE_LABEL(MSG_META_TOGGLE_PLAYER_OBSERVER) + CASE_LABEL(MSG_META_CYCLE_HEALTH_BAR_MODE) CASE_LABEL(MSG_META_BEGIN_PATH_BUILD) CASE_LABEL(MSG_META_END_PATH_BUILD) CASE_LABEL(MSG_META_BEGIN_FORCEATTACK) diff --git a/Core/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp b/Core/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp index c2835253d30..169e7c191b8 100644 --- a/Core/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp +++ b/Core/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp @@ -59,6 +59,8 @@ #include "GameClient/GameClient.h" #include "GameClient/GameWindowManager.h" #include "GameClient/GameText.h" +// TheSuperHackers @feature for the health bar display mode cycle +#include "Common/OptionPreferences.h" #include "GameClient/ParticleSys.h" #include "GameClient/GUICallbacks.h" #include "GameClient/Shell.h" @@ -3455,6 +3457,44 @@ GameMessageDisposition CommandTranslator::translateGameMessage(const GameMessage break; } + //----------------------------------------------------------------------------------------- +#if RTS_ZEROHOUR + // TheSuperHackers @feature Cycle the health bar display mode in game, so the player can + // switch between Classic, Damaged and Always without leaving to edit Options.ini. Purely a + // client side display setting -- nothing here reaches the simulation, so it is safe in + // multiplayer and replays. The change is not written back to Options.ini, so it lasts for + // the session only and the configured mode returns next launch. + case GameMessage::MSG_META_CYCLE_HEALTH_BAR_MODE: + { + if( TheWritableGlobalData ) + { + Int mode = TheGlobalData->m_healthBarDisplayMode + 1; + if( mode >= HealthBarDisplayMode_Count || mode < 0 ) + mode = HealthBarDisplayMode_Classic; + + TheWritableGlobalData->m_healthBarDisplayMode = mode; + + switch( mode ) + { + case HealthBarDisplayMode_Damaged: + TheInGameUI->messageNoFormat( TheGameText->FETCH_OR_SUBSTITUTE( + "GUI:HealthBarModeDamaged", L"Health bars: damaged only" ) ); + break; + case HealthBarDisplayMode_Always: + TheInGameUI->messageNoFormat( TheGameText->FETCH_OR_SUBSTITUTE( + "GUI:HealthBarModeAlways", L"Health bars: always" ) ); + break; + default: + TheInGameUI->messageNoFormat( TheGameText->FETCH_OR_SUBSTITUTE( + "GUI:HealthBarModeClassic", L"Health bars: classic" ) ); + break; + } + } + disp = DESTROY_MESSAGE; + break; + } +#endif // RTS_ZEROHOUR + //----------------------------------------------------------------------------------------- case GameMessage::MSG_META_TOGGLE_ATTACKMOVE: TheInGameUI->toggleAttackMoveToMode(); diff --git a/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp b/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp index 31a954e67b5..491fca02db0 100644 --- a/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp +++ b/Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp @@ -159,6 +159,7 @@ static const LookupListRec GameMessageMetaTypeNames[] = { "TOGGLE_LOWER_DETAILS", GameMessage::MSG_META_TOGGLE_LOWER_DETAILS }, { "TOGGLE_CONTROL_BAR", GameMessage::MSG_META_TOGGLE_CONTROL_BAR }, { "TOGGLE_PLAYER_OBSERVER", GameMessage::MSG_META_TOGGLE_PLAYER_OBSERVER }, + { "CYCLE_HEALTH_BAR_MODE", GameMessage::MSG_META_CYCLE_HEALTH_BAR_MODE }, { "BEGIN_PATH_BUILD", GameMessage::MSG_META_BEGIN_PATH_BUILD }, { "END_PATH_BUILD", GameMessage::MSG_META_END_PATH_BUILD }, { "BEGIN_FORCEATTACK", GameMessage::MSG_META_BEGIN_FORCEATTACK }, @@ -860,6 +861,23 @@ void MetaMap::generateMetaMap() map->m_usableIn = COMMANDUSABLE_OBSERVER; } } +#if RTS_ZEROHOUR + { + // TheSuperHackers @feature Cycle the health bar display mode. Ctrl+` is unbound in + // retail and in the debug and demo command maps, so this takes a key nothing else wants. + // Note the tick key is layout dependent -- it is ` on a US keyboard but prints something + // else elsewhere -- however the binding is by scancode, so the same physical key works. + MetaMapRec *map = TheMetaMap->getMetaMapRec(GameMessage::MSG_META_CYCLE_HEALTH_BAR_MODE); + if (map->m_key == MK_NONE) + { + map->m_key = MK_TICK; + map->m_transition = DOWN; + map->m_modState = CTRL; + // in game and while observing, but not in the menus, where health bars mean nothing + map->m_usableIn = (CommandUsableInType)(COMMANDUSABLE_GAME | COMMANDUSABLE_OBSERVER); + } + } +#endif // RTS_ZEROHOUR { // Is mostly useful for Generals. MetaMapRec *map = getMetaMapRec(GameMessage::MSG_META_TOGGLE_FAST_FORWARD_REPLAY); From 2dd4ececc81eacb756cff56c0d3c50f3e0f9ddc3 Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:41:32 +0300 Subject: [PATCH 3/5] feat: Add NumericalHealth to print hit points beside the health bar Shows current and max hit points to the right of the bar, so exact values are readable without inferring them from bar length. Hooked at the end of drawHealthBar rather than as a parallel draw path, which means every rule that already decides whether a bar appears applies unchanged -- selection, mouseover, HealthBarDisplayMode and the dead and scenery exclusions. The number therefore shows exactly where a bar shows, in all three modes, with no duplicated visibility logic to drift out of step. It reuses the bar's own colour, so it carries the same red to green reading at a glance, including the blue to cyan variant for objects under construction or disabled, and the damaged tinting. Drawn small and unbold: in Always mode this lands over every unit on screen at once, so it has to annotate the bar rather than compete with it. A drop shadow rather than a backdrop plate, since this sits over the battlefield and not over a cameo. Values are rounded rather than truncated, so a unit with a sliver of health left does not read as 0. Options.ini: NumericalHealth = Yes --- .../Include/Common/OptionPreferences.h | 1 + .../Source/Common/OptionPreferences.cpp | 14 +++++ .../GameEngine/Include/Common/GlobalData.h | 2 + .../GameEngine/Include/GameClient/Drawable.h | 3 + .../GameEngine/Source/Common/GlobalData.cpp | 2 + .../GameEngine/Source/GameClient/Drawable.cpp | 60 +++++++++++++++++++ 6 files changed, 82 insertions(+) diff --git a/Core/GameEngine/Include/Common/OptionPreferences.h b/Core/GameEngine/Include/Common/OptionPreferences.h index 01b2534dac0..83db30942a0 100644 --- a/Core/GameEngine/Include/Common/OptionPreferences.h +++ b/Core/GameEngine/Include/Common/OptionPreferences.h @@ -84,6 +84,7 @@ class OptionPreferences : public UserPreferences Bool getRightMouseScrollWithAlternateMouseEnabled() const; Bool getRetaliationModeEnabled(); HealthBarDisplayMode getHealthBarDisplayMode() const; + Bool getNumericalHealthEnabled() const; Bool getDoubleClickAttackMoveEnabled(); Int getJpegQuality() const; Real getScrollFactor(); diff --git a/Core/GameEngine/Source/Common/OptionPreferences.cpp b/Core/GameEngine/Source/Common/OptionPreferences.cpp index 47ec48e2562..3d23241e26a 100644 --- a/Core/GameEngine/Source/Common/OptionPreferences.cpp +++ b/Core/GameEngine/Source/Common/OptionPreferences.cpp @@ -239,6 +239,20 @@ HealthBarDisplayMode OptionPreferences::getHealthBarDisplayMode() const return HealthBarDisplayMode_Default; } +// TheSuperHackers @feature Options.ini: NumericalHealth = Yes prints the hit points beside the +// health bar. Follows HealthBarDisplayMode, so the number appears exactly where a bar does. +Bool OptionPreferences::getNumericalHealthEnabled() const +{ + OptionPreferences::const_iterator it = find("NumericalHealth"); + 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/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h index e386728d2b3..cec4f505349 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h @@ -146,6 +146,8 @@ class GlobalData : public SubsystemInterface // Holds a HealthBarDisplayMode; stored as Int so this widely included header does not // have to pull in OptionPreferences.h. Int m_healthBarDisplayMode; + // TheSuperHackers @feature Print hit points beside the health bar. + Bool m_numericalHealth; Bool m_doubleClickAttackMove; Bool m_rightMouseAlwaysScrolls; Int m_jpegQuality; // TheSuperHackers @feature Quality for JPEG screenshots. diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/Drawable.h b/GeneralsMD/Code/GameEngine/Include/GameClient/Drawable.h index 7169b8cb51f..4b2d269c8f5 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/Drawable.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/Drawable.h @@ -755,6 +755,9 @@ class Drawable : public Thing, void drawConstructPercent( const IRegion2D *healthBarRegion ); ///< display % construction complete void drawCaption( const IRegion2D *healthBarRegion ); ///< draw caption void drawAmmo( const IRegion2D *healthBarRegion ); ///< draw icons + // TheSuperHackers @feature hit points beside the bar (Options.ini: NumericalHealth) + void drawNumericalHealth( const IRegion2D *healthBarRegion, Real health, Real maxHealth, + Color color ); void drawContained( const IRegion2D *healthBarRegion ); ///< draw icons void drawVeterancy( const IRegion2D *healthBarRegion ); ///< draw veterency information diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp index 72001289446..f743739f6b2 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp @@ -1065,6 +1065,7 @@ GlobalData::GlobalData() m_doubleClickAttackMove = FALSE; // TheSuperHackers @feature Health bars behave exactly as they always have unless Options.ini says otherwise. m_healthBarDisplayMode = HealthBarDisplayMode_Default; + m_numericalHealth = FALSE; } @@ -1208,6 +1209,7 @@ void GlobalData::parseGameDataDefinition( INI* ini ) TheWritableGlobalData->m_useRightMouseScrollWithAlternateMouse = optionPref.getRightMouseScrollWithAlternateMouseEnabled(); TheWritableGlobalData->m_clientRetaliationModeEnabled = optionPref.getRetaliationModeEnabled(); TheWritableGlobalData->m_healthBarDisplayMode = optionPref.getHealthBarDisplayMode(); + TheWritableGlobalData->m_numericalHealth = optionPref.getNumericalHealthEnabled(); TheWritableGlobalData->m_doubleClickAttackMove = optionPref.getDoubleClickAttackMoveEnabled(); TheWritableGlobalData->m_jpegQuality = optionPref.getJpegQuality(); TheWritableGlobalData->m_keyboardScrollFactor = optionPref.getScrollFactor(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp index 6ae0e9e8837..299bc0ead9b 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp @@ -3801,6 +3801,59 @@ static Bool isHealthBarAlwaysVisible( const Object *obj, Real healthRatio ) return obj->isKindOf( KINDOF_STRUCTURE ) || obj->getAI() != nullptr; } +//------------------------------------------------------------------------------------------------- +// TheSuperHackers @feature Print the hit points beside the health bar (Options.ini: +// NumericalHealth). Called from drawHealthBar once the bar itself is down, so every rule that +// decides whether a bar is drawn -- selection, mouseover, HealthBarDisplayMode -- already applies. +//------------------------------------------------------------------------------------------------- +void Drawable::drawNumericalHealth( const IRegion2D *healthBarRegion, Real health, Real maxHealth, + Color color ) +{ + if( healthBarRegion == nullptr || TheDisplayStringManager == nullptr ) + return; + + // One shared string, rebuilt whenever the text changes. Unlike the command bar overlays there + // is no small fixed set of values to cache per object, and many bars draw per frame, so this + // is rebuilt as often as it is reused. It stays static purely to avoid allocating every call. + static DisplayString *s_healthString = nullptr; + + if( s_healthString == nullptr ) + { + s_healthString = TheDisplayStringManager->newDisplayString(); + if( s_healthString == nullptr ) + return; + + // Small on purpose: in Always mode this is drawn over every unit on screen at once, so it + // has to annotate the bar rather than compete with it. + Int pointSize = 6; + if( TheGlobalLanguageData ) + pointSize = TheGlobalLanguageData->adjustFontSize( pointSize ); + s_healthString->setFont( TheFontLibrary->getFont( AsciiString( "Arial" ), pointSize, FALSE ) ); + } + + // Round rather than truncate, so a sliver of health left does not read as 0 next to a unit + // that is plainly still alive. + const Int shownHealth = REAL_TO_INT( health + 0.5f ); + const Int shownMax = REAL_TO_INT( maxHealth + 0.5f ); + + UnicodeString text; + text.format( L"%d/%d", shownHealth, shownMax ); + s_healthString->setText( text ); + + Int width, height; + s_healthString->getSize( &width, &height ); + + // just past the right end of the bar, vertically centred on it + const Int healthBoxHeight = max( 3, healthBarRegion->hi.y - healthBarRegion->lo.y ); + const Int textX = healthBarRegion->hi.x + 2; + const Int textY = healthBarRegion->lo.y + ( healthBoxHeight / 2 ) - ( height / 2 ); + + // Black drop shadow rather than a backdrop plate: the number sits over the battlefield rather + // than over a cameo, so a filled box would be far more intrusive than the bar it annotates. + s_healthString->draw( textX, textY, color, GameMakeColor( 0, 0, 0, 255 ) ); +} + +//------------------------------------------------------------------------------------------------- void Drawable::drawHealthBar(const IRegion2D* healthBarRegion) { if (!healthBarRegion) @@ -3925,6 +3978,13 @@ void Drawable::drawHealthBar(const IRegion2D* healthBarRegion) TheDisplay->drawFillRect( healthBarRegion->lo.x + 1, healthBarRegion->lo.y + 1, (healthBoxWidth - 2) * healthRatio, healthBoxHeight - 2, color ); + + // TheSuperHackers @feature NumericalHealth prints the hit points just past the end of the + // bar. It reuses the bar's own colour, so the number carries the same red to green reading + // at a glance, and it hangs off every path that got this far -- which means it follows + // HealthBarDisplayMode for free and appears exactly where a bar appears. + if( TheGlobalData->m_numericalHealth ) + drawNumericalHealth( healthBarRegion, health, maxHealth, color ); } } From ffaf4f7bc435438291aa93709ead687e78d06149 Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:23:15 +0300 Subject: [PATCH 4/5] feat: Add SmartPips to keep ammo and passenger pips on screen Both pip types normally appear only while a unit is selected or moused over, and only with the ShowObjectHealth debug flag on. SmartPips shows them whenever there is something to report, so remaining ammo and loaded transports read at a glance. Own units only -- not allies, not enemies -- since standing pips over units the player does not control would give away information the game otherwise withholds. Nothing is drawn when there is nothing to report. Passenger pips already bailed on an empty transport; ammo pips needed a new early out, placed before the style switch so every style behaves the same, because the default style deliberately draws empty boxes for spent shots. Drawing these every frame for every owned unit rather than only for a selected one exposed assumptions the container path was quietly making, so three latent faults are guarded here as well. The contained items list can hold a null and entries can be mid removal while a transport unloads. numFull comes from getContainerPipsToShow, which counts extra slots in use and may be redirected to another module entirely -- OverlordContain forwards it to its sub container -- while the list is always the outer container's own, so the two need not agree. And the pip images were dereferenced unguarded, where drawAmmo has always checked its own. Options.ini: SmartPips = Yes --- .../Include/Common/OptionPreferences.h | 1 + .../Source/Common/OptionPreferences.cpp | 16 ++++ .../GameEngine/Include/Common/GlobalData.h | 3 + .../GameEngine/Source/Common/GlobalData.cpp | 2 + .../GameEngine/Source/GameClient/Drawable.cpp | 87 ++++++++++++++++--- 5 files changed, 97 insertions(+), 12 deletions(-) diff --git a/Core/GameEngine/Include/Common/OptionPreferences.h b/Core/GameEngine/Include/Common/OptionPreferences.h index 83db30942a0..5bd50c1c325 100644 --- a/Core/GameEngine/Include/Common/OptionPreferences.h +++ b/Core/GameEngine/Include/Common/OptionPreferences.h @@ -85,6 +85,7 @@ class OptionPreferences : public UserPreferences Bool getRetaliationModeEnabled(); HealthBarDisplayMode getHealthBarDisplayMode() const; Bool getNumericalHealthEnabled() const; + Bool getSmartPipsEnabled() const; Bool getDoubleClickAttackMoveEnabled(); Int getJpegQuality() const; Real getScrollFactor(); diff --git a/Core/GameEngine/Source/Common/OptionPreferences.cpp b/Core/GameEngine/Source/Common/OptionPreferences.cpp index 3d23241e26a..1258f420c4b 100644 --- a/Core/GameEngine/Source/Common/OptionPreferences.cpp +++ b/Core/GameEngine/Source/Common/OptionPreferences.cpp @@ -253,6 +253,22 @@ Bool OptionPreferences::getNumericalHealthEnabled() const return FALSE; } +// TheSuperHackers @feature Options.ini: SmartPips = Yes keeps ammo and passenger pips on screen +// instead of showing them only while the unit is selected or moused over. Own units only -- not +// allies, not enemies. Nothing is drawn when there is nothing to report: no shots left, or no +// one aboard. So the pips read as "still loaded" and "carrying someone" at a glance. +Bool OptionPreferences::getSmartPipsEnabled() const +{ + OptionPreferences::const_iterator it = find("SmartPips"); + 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/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h index cec4f505349..b159618ce3d 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h @@ -148,6 +148,9 @@ class GlobalData : public SubsystemInterface Int m_healthBarDisplayMode; // TheSuperHackers @feature Print hit points beside the health bar. Bool m_numericalHealth; + // TheSuperHackers @feature Keep ammo and passenger pips on screen when there is something + // to report, rather than only on selection or hover. + Bool m_smartPips; 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 f743739f6b2..bf99c515eca 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp @@ -1066,6 +1066,7 @@ GlobalData::GlobalData() // TheSuperHackers @feature Health bars behave exactly as they always have unless Options.ini says otherwise. m_healthBarDisplayMode = HealthBarDisplayMode_Default; m_numericalHealth = FALSE; + m_smartPips = FALSE; } @@ -1210,6 +1211,7 @@ void GlobalData::parseGameDataDefinition( INI* ini ) TheWritableGlobalData->m_clientRetaliationModeEnabled = optionPref.getRetaliationModeEnabled(); TheWritableGlobalData->m_healthBarDisplayMode = optionPref.getHealthBarDisplayMode(); TheWritableGlobalData->m_numericalHealth = optionPref.getNumericalHealthEnabled(); + TheWritableGlobalData->m_smartPips = optionPref.getSmartPipsEnabled(); TheWritableGlobalData->m_doubleClickAttackMove = optionPref.getDoubleClickAttackMoveEnabled(); TheWritableGlobalData->m_jpegQuality = optionPref.getJpegQuality(); TheWritableGlobalData->m_keyboardScrollFactor = optionPref.getScrollFactor(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp index 299bc0ead9b..7db6d29f8ea 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp @@ -2847,18 +2847,41 @@ void Drawable::drawAmmo( const IRegion2D *healthBarRegion ) { const Object *obj = getObject(); - if (!( - TheGlobalData->m_showObjectHealth && - (isSelected() || (TheInGameUI && (TheInGameUI->getMousedOverDrawableID() == getID()))) && - obj->getControllingPlayer() == rts::getObservedOrLocalPlayer() - )) - return; + // TheSuperHackers @feature SmartPips shows a unit's ammo pips all the time rather than only + // while it is selected or moused over, so remaining ammo is readable at a glance. Restricted + // to the player's own units -- not allies, not enemies -- since standing ammo counts on units + // you do not control would be information the game does not otherwise give away. A unit with + // no shots left draws nothing at all, so the pips read as "this one is still loaded". + const Bool smartPips = TheGlobalData->m_smartPips; + + if (!smartPips) + { + if (!( + TheGlobalData->m_showObjectHealth && + (isSelected() || (TheInGameUI && (TheInGameUI->getMousedOverDrawableID() == getID()))) && + obj->getControllingPlayer() == rts::getObservedOrLocalPlayer() + )) + return; + } + else + { + if (obj->getControllingPlayer() != rts::getObservedOrLocalPlayer()) + return; + } Int numTotal; Int numFull; if (!obj->getAmmoPipShowingInfo(numTotal, numFull)) return; + // TheSuperHackers @feature With SmartPips the pips are on screen the whole time, so an empty + // unit would otherwise sit under a permanent row of empty boxes. Draw nothing at all instead, + // which also makes an out of ammo unit obvious by the pips vanishing. Without SmartPips the + // pips only appear on selection or hover, where showing the empties is the informative thing + // to do, so this does not apply. + if (smartPips && numFull <= 0) + return; + if (!s_fullAmmo || !s_emptyAmmo) return; @@ -2905,13 +2928,35 @@ void Drawable::drawContained( const IRegion2D *healthBarRegion ) if (!container) return; - if (!( - TheGlobalData->m_showObjectHealth && - (isSelected() || (TheInGameUI && (TheInGameUI->getMousedOverDrawableID() == getID()))) && - obj->getControllingPlayer() == rts::getObservedOrLocalPlayer() - )) + // The pips are left justified against the health bar, so without that region there is nowhere + // to put them. It is null when the object is off screen or has no health box. The selected or + // moused over test used to make a valid region implicit; SmartPips draws without that test, so + // check it outright rather than dereference null further down. + if (!healthBarRegion) return; + // TheSuperHackers @feature SmartPips keeps the passenger pips on screen rather than showing + // them only on selection or hover, matching what it does for ammo pips. Own units only -- not + // allies, not enemies -- so this never reveals cargo the player could not already see. An + // empty transport draws nothing either way, thanks to the numFull check below, so the pips + // read as "this one is carrying someone". + const Bool smartPips = TheGlobalData->m_smartPips; + + if (!smartPips) + { + if (!( + TheGlobalData->m_showObjectHealth && + (isSelected() || (TheInGameUI && (TheInGameUI->getMousedOverDrawableID() == getID()))) && + obj->getControllingPlayer() == rts::getObservedOrLocalPlayer() + )) + return; + } + else + { + if (obj->getControllingPlayer() != rts::getObservedOrLocalPlayer()) + return; + } + Int numTotal; Int numFull; if (!container->getContainerPipsToShow(numTotal, numFull)) @@ -2921,22 +2966,40 @@ void Drawable::drawContained( const IRegion2D *healthBarRegion ) if (numFull == 0) return; + // TheSuperHackers @fix SmartPips draws these every frame for every owned container rather than + // only for a selected one, which exposed two assumptions this loop was quietly making. + // + // The list can hold a null, and an entry can be mid removal while a transport is unloading, so + // each one is checked before use rather than dereferenced outright. And numFull comes from + // getContainerPipsToShow, which counts getContainCount() plus getExtraSlotsInUse() and may be + // redirected to a different module entirely -- OverlordContain forwards it to its sub container + // -- while this list is always the outer container's own. The two therefore need not agree, so + // numInfantry is clamped to numFull rather than assumed to be within it. Int numInfantry = 0; const ContainedItemsList* contained = container->getContainedItemsList(); if (contained) { for (ContainedItemsList::const_iterator it = contained->begin(); it != contained->end(); ++it) { - if ((*it)->isKindOf(KINDOF_INFANTRY)) + const Object* item = *it; + if (item && item->isKindOf(KINDOF_INFANTRY)) ++numInfantry; } } + if (numInfantry > numFull) + numInfantry = numFull; + #ifdef SCALE_ICONS_WITH_ZOOM_ML Real scale = TheGlobalData->m_ammoPipScaleFactor / CLAMP_ICON_ZOOM_FACTOR( TheTacticalView->getZoom() ); #else Real scale = 1.0f; #endif + // drawAmmo guards its images the same way. Previously a missing pip image would only crash on + // selecting a transport; with SmartPips it would crash on sight of one. + if (!s_fullContainer || !s_emptyContainer) + return; + Int boxWidth = REAL_TO_INT(s_emptyContainer->getImageWidth() * scale); Int boxHeight = REAL_TO_INT(s_emptyContainer->getImageHeight() * scale); const Int SPACING = 1; From b05927fb0e5388e5759f81f09fa8add835ad9e8c Mon Sep 17 00:00:00 2001 From: triatomic <32312517+triatomic@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:00:20 +0300 Subject: [PATCH 5/5] fix(client): Guard the ammo pip health region and free the shared health string Addresses both review findings: - drawAmmo dereferenced healthBarRegion unchecked. The selection test made a valid region implicit, but SmartPips draws without that test, so an owned unit whose health region cannot be computed crashed instead of skipping its pips. Checked outright now, matching drawContained. - The shared numerical health DisplayString stayed registered with TheDisplayStringManager through shutdown, asserting in debug builds and leaking in release. It is now file scoped with a teardown hook that GameClient's destructor runs right before deleting the manager - the existing killStaticImages runs after the manager is gone, so it could not take this job. --- .../GameEngine/Include/GameClient/Drawable.h | 2 ++ .../GameEngine/Source/GameClient/Drawable.cpp | 32 ++++++++++++++++--- .../Source/GameClient/GameClient.cpp | 4 +++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/Drawable.h b/GeneralsMD/Code/GameEngine/Include/GameClient/Drawable.h index 4b2d269c8f5..e775a497172 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/Drawable.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/Drawable.h @@ -739,6 +739,8 @@ class Drawable : public Thing, //Perhaps we can move this out of Drawable??? public: static void killStaticImages(); + // TheSuperHackers @feature Free shared display strings before the manager is destroyed. + static void killStaticDisplayStrings(); #ifdef DIRTY_CONDITION_FLAGS // only for StDrawableDirtyStuffLocker! diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp index 7db6d29f8ea..cbce0468878 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/Drawable.cpp @@ -305,6 +305,26 @@ const Int MAX_ENABLED_MODULES = 16; s_animationTemplates = nullptr; } +// TheSuperHackers @feature One shared string for the numerical health text, rebuilt whenever the +// text changes. Unlike the command bar overlays there is no small fixed set of values to cache +// per object, and many bars draw per frame, so it is rebuilt as often as it is reused. It stays +// static purely to avoid allocating every call, and is returned to the manager before the manager +// itself is torn down -- see Drawable::killStaticDisplayStrings. +static DisplayString *s_healthString = nullptr; + +//------------------------------------------------------------------------------------------------- +/** Return the shared display strings to the manager. Must run before TheDisplayStringManager is + * destroyed, which asserts on any string still registered -- GameClient's destructor calls this + * right before deleting the manager, unlike killStaticImages, which runs after. */ +//------------------------------------------------------------------------------------------------- +/*static*/ void Drawable::killStaticDisplayStrings() +{ + if( s_healthString != nullptr && TheDisplayStringManager != nullptr ) + TheDisplayStringManager->freeDisplayString( s_healthString ); + + s_healthString = nullptr; +} + //------------------------------------------------------------------------------------------------- void Drawable::saturateRGB(RGBColor& color, Real factor) { @@ -2847,6 +2867,13 @@ void Drawable::drawAmmo( const IRegion2D *healthBarRegion ) { const Object *obj = getObject(); + // The pips are left justified against the health bar, so without that region there is nowhere + // to put them. It is null when the object is off screen or has no health box. The selected or + // moused over test used to make a valid region implicit; SmartPips draws without that test, so + // check it outright rather than dereference null further down. + if (!healthBarRegion) + return; + // TheSuperHackers @feature SmartPips shows a unit's ammo pips all the time rather than only // while it is selected or moused over, so remaining ammo is readable at a glance. Restricted // to the player's own units -- not allies, not enemies -- since standing ammo counts on units @@ -3875,11 +3902,6 @@ void Drawable::drawNumericalHealth( const IRegion2D *healthBarRegion, Real healt if( healthBarRegion == nullptr || TheDisplayStringManager == nullptr ) return; - // One shared string, rebuilt whenever the text changes. Unlike the command bar overlays there - // is no small fixed set of values to cache per object, and many bars draw per frame, so this - // is rebuilt as often as it is reused. It stays static purely to avoid allocating every call. - static DisplayString *s_healthString = nullptr; - if( s_healthString == nullptr ) { s_healthString = TheDisplayStringManager->newDisplayString(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GameClient.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GameClient.cpp index af5f407209b..32de65faa39 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GameClient.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GameClient.cpp @@ -228,6 +228,10 @@ GameClient::~GameClient() delete TheKeyboard; TheKeyboard = nullptr; + // TheSuperHackers @feature The shared numerical health string must go back to the manager + // while the manager is still alive; its destructor asserts on any string left registered. + Drawable::killStaticDisplayStrings(); + delete TheDisplayStringManager; TheDisplayStringManager = nullptr;