From 66e2f73b77a741fdfbe3416b63f7d8e47d655087 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:08:24 +0300 Subject: [PATCH 01/42] splitscreen: only seat 0's drag-select lasso was ever drawn W3DInGameUI::draw() runs once per frame rather than once per view, and it tested m_seatContexts[0].m_isDragSelecting alone - so a pad seat's lasso was tracked correctly and then never painted. drawSelectionRegion() likewise read seat 0's region unconditionally. It now takes a seat and draw() loops every seat, skipping any seat>0 that has lost its viewport mid-drag so it cannot paint a frozen box. The region is already in absolute screen pixels, so no per-seat transform is needed. Drawing those seats exposes a latent bug that was invisible while nothing painted them: m_dragSelecting/m_dragSeat in SelectionTranslator are ONE state machine shared by all seats, so a second seat pressing steals the drag and leaves the first seat's m_isDragSelecting stuck TRUE - permanently, because that seat's own button-up then takes the else branch and never calls endAreaSelectHint. RAW_MOUSE_LEFT_BUTTON_DOWN now hands the previous owner's lasso back first, via a new InGameUI::endAreaSelectHintForSeat() (the seat being ended is not the seat being translated, so m_activeSeat cannot name it - same reason c2a26a4e8 needed messageForSeat). reset() also clears every seat's drag flag, which nothing did before, so a lasso live at match end cannot survive into the next match. Single-view is byte-identical: seats 1-7 never set the flag, the pre-emption branch cannot fire with one seat, and endAreaSelectHint still resolves m_activeSeat for all five existing callers. --- .../GameEngine/Include/GameClient/InGameUI.h | 4 ++ .../GameEngine/Source/GameClient/InGameUI.cpp | 21 ++++++++++- .../MessageStream/SelectionXlat.cpp | 12 ++++++ .../W3DDevice/GameClient/W3DInGameUI.h | 2 +- .../W3DDevice/GameClient/W3DInGameUI.cpp | 37 +++++++++++++++---- 5 files changed, 66 insertions(+), 10 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h index 0f3c496ef22..9bdf86dce1e 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h @@ -405,6 +405,10 @@ friend class Drawable; // for selection/deselection transactions // interface for graphical "hints" which provide visual feedback for user-interface commands virtual void beginAreaSelectHint( const GameMessage *msg ); ///< Used by HintSpy. An area selection is occurring, start graphical "hint" virtual void endAreaSelectHint( const GameMessage *msg ); ///< Used by HintSpy. An area selection had occurred, finish graphical "hint" + // Splitscreen: same as endAreaSelectHint(), but ends one specific seat's drag instead of + // whichever seat m_activeSeat resolves to. Used when one seat's drag is pre-empted by + // another seat pressing, where the pre-empted seat is not the one being translated. + virtual void endAreaSelectHintForSeat( Int seat ); virtual void createMoveHint( const GameMessage *msg ); ///< A move command has occurred, start graphical "hint" virtual void createAttackHint( const GameMessage *msg ); ///< An attack command has occurred, start graphical "hint" virtual void createForceAttackHint( const GameMessage *msg ); ///< A force attack command has occurred, start graphical "hint" diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index 1367da50360..d79e6628e28 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -2229,6 +2229,12 @@ void InGameUI::reset() // drawn - relying on that alone let the extra bars survive into the main menu. ControlBarInstances::destroySeatInstances(); + // Splitscreen: clear every seat's drag flag on the way out of a match. Nothing else does, + // so a seat that was mid-lasso when the match ended would carry m_isDragSelecting into the + // next one and paint a frozen box from the old match's coordinates. + for( Int dragSeat = 0; dragSeat < MAX_SEATS; ++dragSeat ) + m_seatContexts[ dragSeat ].m_isDragSelecting = false; + // reset the command bar TheControlBar->reset(); @@ -2585,7 +2591,20 @@ void InGameUI::beginAreaSelectHint( const GameMessage *msg ) //------------------------------------------------------------------------------------------------- void InGameUI::endAreaSelectHint( const GameMessage *msg ) { - m_seatContexts[m_activeSeat].m_isDragSelecting = false; + endAreaSelectHintForSeat( m_activeSeat ); +} + +//------------------------------------------------------------------------------------------------- +/** End one named seat's area selection hint. Splitscreen: the seat whose drag is ending is not + * always the seat being translated - a seat pre-empted by another seat pressing is not, and + * its own button-up cannot clean it up because the shared drag state has already moved on. */ +//------------------------------------------------------------------------------------------------- +void InGameUI::endAreaSelectHintForSeat( Int seat ) +{ + if( seat < 0 || seat >= MAX_SEATS ) + seat = m_activeSeat; + + m_seatContexts[ seat ].m_isDragSelecting = false; } //------------------------------------------------------------------------------------------------- diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp index 7855f036188..75a9e75b850 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp @@ -916,6 +916,18 @@ GameMessageDisposition SelectionTranslator::translateGameMessage(const GameMessa { // cannot actually start area selection yet - have to wait for cursor to move a bit m_leftMouseButtonIsDown = true; + + // Splitscreen: m_dragSelecting/m_dragSeat are a SINGLE state machine shared by every + // seat, so a second seat pressing silently steals the drag from the first. Hand the + // previous owner's lasso back before taking it, or its m_isDragSelecting stays TRUE + // forever - its own button-up takes the else branch below, since m_dragSelecting is + // FALSE by then, and never calls endAreaSelectHint. + if( m_dragSelecting && m_dragSeat >= 0 && m_dragSeat != msg->getSeatIndex() ) + { + TheInGameUI->endAreaSelectHintForSeat( m_dragSeat ); + m_dragSelecting = FALSE; + } + m_dragSeat = msg->getSeatIndex(); // splitscreen: this seat owns the drag m_selectFeedbackAnchor = msg->getArgument( 0 )->pixel; break; diff --git a/GeneralsMD/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DInGameUI.h b/GeneralsMD/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DInGameUI.h index 7197caf0e51..960169ace9d 100644 --- a/GeneralsMD/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DInGameUI.h +++ b/GeneralsMD/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DInGameUI.h @@ -78,7 +78,7 @@ class W3DInGameUI : public InGameUI return NEW W3DView; } - virtual void drawSelectionRegion(); ///< draw the selection region on screen + virtual void drawSelectionRegion( Int seat ); ///< draw one seat's selection region on screen virtual void drawMoveHints( View *view ); ///< draw move hint visual feedback virtual void drawAttackHints( View *view ); ///< draw attack hint visual feedback virtual void drawPlaceAngle( View *view ); ///< draw place building angle if needed diff --git a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DInGameUI.cpp b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DInGameUI.cpp index f5a3d852a33..24aa8b8b387 100644 --- a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DInGameUI.cpp +++ b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DInGameUI.cpp @@ -399,9 +399,24 @@ void W3DInGameUI::draw() TheDisplay->beginBatch(); preDraw(); - // draw selection region if drag selecting - if( m_seatContexts[0].m_isDragSelecting ) - drawSelectionRegion(); + // draw the selection region for EVERY seat that is drag selecting, not just seat 0. + // draw() runs once per frame rather than once per view, so this loop is the only place + // a pad seat's lasso can be painted; the per-seat state itself was already correct. + for( Int seat = 0; seat < MAX_SEATS; ++seat ) + { + if( m_seatContexts[ seat ].m_isDragSelecting == FALSE ) + continue; + + // a seat that lost its viewport mid-drag would otherwise paint a frozen box forever + if( seat != 0 ) + { + LocalSeat *localSeat = TheSeatManager ? TheSeatManager->getSeat( seat ) : nullptr; + if( localSeat == nullptr || localSeat->m_view == nullptr ) + continue; + } + + drawSelectionRegion( seat ); + } // for each view draw hints /// @todo should the UI be iterating through views like this? @@ -456,15 +471,21 @@ void W3DInGameUI::draw() //------------------------------------------------------------------------------------------------- /** draw 2d selection region on screen */ //------------------------------------------------------------------------------------------------- -void W3DInGameUI::drawSelectionRegion() +void W3DInGameUI::drawSelectionRegion( Int seat ) { + if( seat < 0 || seat >= MAX_SEATS ) + return; + Real width = 2.0f; UnsignedInt color = 0x9933FF33; //0xAARRGGBB - TheDisplay->drawOpenRect( m_seatContexts[0].m_dragSelectRegion.lo.x, - m_seatContexts[0].m_dragSelectRegion.lo.y, - m_seatContexts[0].m_dragSelectRegion.hi.x - m_seatContexts[0].m_dragSelectRegion.lo.x, - m_seatContexts[0].m_dragSelectRegion.hi.y - m_seatContexts[0].m_dragSelectRegion.lo.y, + // the region is already in absolute screen pixels, so no per-seat viewport transform + const IRegion2D ®ion = m_seatContexts[ seat ].m_dragSelectRegion; + + TheDisplay->drawOpenRect( region.lo.x, + region.lo.y, + region.hi.x - region.lo.x, + region.hi.y - region.lo.y, width, color ); From 7c67ce432923e97800e4a06aab9e6c1ee89e0748 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:11:26 +0300 Subject: [PATCH 02/42] splitscreen: an AI spectator seat had the army it watches HQ-selected findAndSelectCommandCenter resolved the seat with getSeatIndexForPlayer(), which answers 'whose viewport shows this player', not 'who is playing it'. An observer seat watching a live AI resolves to a real seat index exactly like a human seat, so the watched army's command centre was selected into the spectator's own selection context - and since round 3k made every ControlBar correctly read its own seat's selection, the now-correct per-seat highlighting faithfully displayed it for the whole match. Rather than change getSeatIndexForPlayer(), which several callers rightly want the watching semantic from, this adds getCommandingSeatIndexForPlayer() as a thin filter over it: same answer, except an observer seat returns -1. The defeat broadcast in VictoryConditions.cpp:213 deliberately keeps the watching form - an observer viewport SHOULD show 'Player N has been defeated' for the AI it is watching - which is the call site that would have silently regressed had the exclusion gone inside the shared function. Corrected that function's doc comment, which claimed 'commands' while the body has always meant 'watches'. GameLogic::selectObject takes the commanding form too, so no logic-driven selection of any kind can land in a spectator's context. Single-view is unchanged on every path: seat 0 can never be an observer (reset clears m_observer, bindFakeSeats only marks seats >= 1, takeOverSeat clears it), so the guard is never taken, and under !RTS_SDL3_ENABLE the new function is a pure passthrough. --- Core/GameEngine/Include/Common/GameUtility.h | 13 ++++++++++++- Core/GameEngine/Source/Common/GameUtility.cpp | 19 +++++++++++++++++++ .../Source/GameLogic/System/GameLogic.cpp | 14 ++++++++++---- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/Core/GameEngine/Include/Common/GameUtility.h b/Core/GameEngine/Include/Common/GameUtility.h index 9a82c77dcd1..01e9d2d0f30 100644 --- a/Core/GameEngine/Include/Common/GameUtility.h +++ b/Core/GameEngine/Include/Common/GameUtility.h @@ -55,7 +55,13 @@ void clearRenderPlayerIndexOverride(); // eight viewports at once. Int getRenderSeatIndex(); -// Splitscreen: which local seat commands the given player, or -1 if none does. +// Splitscreen: which local seat WATCHES the given player, or -1 if none does. +// +// Note the semantic carefully - this answers "whose viewport shows this player as its own", and +// an observer seat watching an AI army answers with its own index here. It does NOT mean anybody +// at that seat is playing that army. For anything that hands a seat control or ownership +// (selection, orders, input ownership) use getCommandingSeatIndexForPlayer() below instead; use +// this one only to route UI feedback to the viewport that is showing that player. // // Seat 0 always answers for ThePlayerList's local player, so in a single-viewport game this is // exactly "is this the local player" and every caller below behaves as it always did. The reason @@ -65,6 +71,11 @@ Int getRenderSeatIndex(); // unit swap) were all asking the first one and acting on seat 0. Int getSeatIndexForPlayer(PlayerIndex playerIndex); +// Splitscreen: as getSeatIndexForPlayer(), except an observer seat (LocalSeat::m_observer) +// answers -1 - nobody at that seat is playing that army, so it must never be handed control of +// it. Selection and ownership ask this one; UI feedback routing asks the watching form above. +Int getCommandingSeatIndexForPlayer(PlayerIndex playerIndex); + // Splitscreen: the player a local seat commands, or -1 before it is bound (and in menus). PlayerIndex getSeatPlayerIndex(Int seatIndex); diff --git a/Core/GameEngine/Source/Common/GameUtility.cpp b/Core/GameEngine/Source/Common/GameUtility.cpp index a0eede6046f..242468bcd6e 100644 --- a/Core/GameEngine/Source/Common/GameUtility.cpp +++ b/Core/GameEngine/Source/Common/GameUtility.cpp @@ -178,6 +178,25 @@ Int getSeatIndexForPlayer(PlayerIndex playerIndex) return -1; } +Int getCommandingSeatIndexForPlayer(PlayerIndex playerIndex) +{ + const Int seat = getSeatIndexForPlayer(playerIndex); + +#if RTS_SDL3_ENABLE + // Seat 0 is the keyboard/mouse and can never be an observer - LocalSeat::reset clears + // m_observer, bindFakeSeats only marks seats >= 1, and takeOverSeat clears it when a real + // pad sits down - so seat 0's answer is returned untouched. + if (seat > 0 && TheSeatManager != nullptr) + { + const LocalSeat* s = TheSeatManager->getSeat(seat); + if (s != nullptr && s->m_observer) + return -1; + } +#endif + + return seat; +} + // Splitscreen: rect of the view being drawn (see header). -1 width = unset. static Int TheRenderViewX = 0, TheRenderViewY = 0, TheRenderViewW = -1, TheRenderViewH = -1; diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp index 1fabc473e57..1be54fae43b 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp @@ -2473,8 +2473,12 @@ static void findAndSelectCommandCenter(Object *obj, void* alreadyFound) // Splitscreen: every LOCAL seat should start with its own command centre selected, so ask // whether any seat commands this player rather than only whether seat 0 does. selectObject // then puts it in that seat's own selection. Outside splitscreen this is isLocallyControlled(). + // + // Ask the COMMANDING form, not the watching one: an observer seat only spectates a live AI, + // so auto-selecting that army's HQ into its context is wrong - nobody there has hands to + // deselect with, and the selection would sit in that viewport's control bar all match. const Bool localToSomeSeat = - rts::getSeatIndexForPlayer(obj->getControllingPlayer()->getPlayerIndex()) >= 0; + rts::getCommandingSeatIndexForPlayer(obj->getControllingPlayer()->getPlayerIndex()) >= 0; TheGameLogic->selectObject(obj, TRUE, obj->getControllingPlayer()->getPlayerMask(), localToSomeSeat); } @@ -2785,9 +2789,11 @@ void GameLogic::selectObject(Object *obj, Bool createNewSelection, PlayerMaskTyp // Splitscreen: select it for the seat that commands THIS player, not for whichever // seat happens to be active. The no-arg accessor resolves to m_activeSeat, which is // 0 everywhere outside message translation - and this runs in the logic - so every - // logic-driven selection (the command centre at level start, a rider swap, a newly - // deployed gunship) landed in player 1's UI whoever it actually belonged to. - const Int seat = rts::getSeatIndexForPlayer( player->getPlayerIndex() ); + // logic-driven selection landed in player 1's UI whoever it actually belonged to. + // + // The commanding form excludes observer seats: a spectator must never have a + // selection pushed into its context by the logic, for any object. + const Int seat = rts::getCommandingSeatIndexForPlayer( player->getPlayerIndex() ); if( seat >= 0 ) TheInGameUI->selectDrawable( draw, seat ); } From d14e21c397013a8ab8b5a2b018bd76764c3faa00 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:12:48 +0300 Subject: [PATCH 03/42] splitscreen: any seat's deselect cancelled player 1's building placement ControlBar::onDrawableDeselected called the legacy 2-arg placeBuildAvailable, which forwards to a literal seat 0 (InGameUI.cpp:3458) rather than to m_activeSeat like the selection family does. Dispatch into the function was already correct - ControlBarInstances::get(seat) routes seat 1's deselect to seat 1's bar - so seat 1's own deselect ran and then cleared SEAT 0's armed placement as a side effect. m_seatIndex was already in scope 11 lines above. Two more instances of the same call, both missed by the handoff and both fixed here because they are clear-only and cannot desynchronise anything: ControlBar::processCommandUI runs its clear BEFORE the command switch, so any seat pressing any control-bar button cancelled player 1's placement; and CommandXlat's right-click cancel had cmdSeat resolved and in use on the lines immediately above it. DELIBERATELY NOT FIXED HERE - the ARM sites (ControlBarCommandProcessing.cpp GUI_COMMAND_DOZER_CONSTRUCT and the two special-power variants). Arm and consume are both pinned to seat 0 today, which is why a pad seat's build currently completes at all, wrongly, through seat 0's context. Passing m_seatIndex to the arm calls alone would write the pending placement into seat N's context while PlaceEventTranslator still reads seat 0's - so seat N would arm a build it could never place, which is worse than the present bug. That pair has to move together, and PlaceEventTranslator also projects through TheTacticalView, so seat N's pixels would go through seat 0's camera. Logged as its own finding rather than half-landed here. Single-view is unchanged: every seat resolves to 0. --- .../Source/GameClient/GUI/ControlBar/ControlBar.cpp | 4 +++- .../GameClient/GUI/ControlBar/ControlBarCommandProcessing.cpp | 4 +++- .../Source/GameClient/MessageStream/CommandXlat.cpp | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp index b7bf17fa55f..0f431564d62 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp @@ -2582,7 +2582,9 @@ void ControlBar::onDrawableDeselected( Drawable *draw ) // we have some and are in the middle of a build process, it must obviously be over now // because we are no longer selecting the dozer or worker // - TheInGameUI->placeBuildAvailable( nullptr, nullptr ); + // Splitscreen: clear THIS bar's seat, not seat 0. The legacy 2-arg overload forwards to a + // literal 0, so a pad seat deselecting a unit was cancelling player 1's armed placement. + TheInGameUI->placeBuildAvailable( nullptr, nullptr, m_seatIndex ); } diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommandProcessing.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommandProcessing.cpp index e659684787c..d3e87dbf653 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommandProcessing.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommandProcessing.cpp @@ -194,7 +194,9 @@ CBCommandStatus ControlBar::processCommandUI( GameWindow *control, obj->markSingleUseCommandUsed(); //Yeah, an object can only use one single use command... } - TheInGameUI->placeBuildAvailable( nullptr, nullptr ); + // Splitscreen: clear THIS bar's seat, not seat 0. This runs before the command switch, so + // without the seat any seat pressing any control-bar button cancelled player 1's placement. + TheInGameUI->placeBuildAvailable( nullptr, nullptr, m_seatIndex ); //Play any available unit specific sound for button Player *player = getBarPlayer(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp index 8d4525839bd..24ef2dd6655 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp @@ -3906,7 +3906,8 @@ GameMessageDisposition CommandTranslator::translateGameMessage(const GameMessage //via the deselect drawable code. if( TheMouse->isClick(&m_mouseRightDragAnchor[cmdSeat], &m_mouseRightDragLift[cmdSeat], m_mouseRightDown[cmdSeat], m_mouseRightUp[cmdSeat]) ) { - TheInGameUI->placeBuildAvailable( nullptr, nullptr ); + // Splitscreen: cancel the ACTING seat's placement, not seat 0's + TheInGameUI->placeBuildAvailable( nullptr, nullptr, cmdSeat ); } break; From ce879e9b5155970a6c35ab2f385e5b7c1599a754 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:16:37 +0300 Subject: [PATCH 04/42] splitscreen: one player's defeat reskinned every seat's control bar Every seat's ControlBar points at TheControlBar's single ControlBarSchemeManager (initAsSeatInstance), and the paint callbacks drew through that manager's m_currentScheme - which only ever holds whatever scheme was applied LAST, by any bar. So when player 1 was defeated and killPlayer set the blank FactionObserver skin, all eight viewports went blank; and in general eight players on different factions all showed one faction's artwork. Each bar now records the scheme it was given, at the multiplier it was given with, and draws with its own: ControlBar::setBarScheme, called from a new ControlBarSchemeManager::applyCurrentSchemeToTargetBar that replaces the five identical 'm_currentScheme->init( takeApplyToBar() )' statements. The multiplier is captured verbatim rather than recomputed, because setControlBarScheme uses integer division while the two by-player paths cast to Real - recomputing would have silently changed the by-name/shell path. ControlBarScheme::drawForeground/drawBackground are pure reads of m_layer[], so several bars drawing one scheme object at different offsets is safe. This also removes the setDrawScale() writes from inside the two draw callbacks. Those were mutating shared manager state from a paint path to work around the same root cause; the scale is now simply passed in. Second, orthogonal bug fixed in the same area: because killPlayer's reskin is behind isLocalPlayer(), which is never true for a seat>0 player, a defeated SEAT player's bar never became an observer bar at all. applySchemeForBarPlayer latched on player template alone, and a defeated player keeps their template. It now also latches on isPlayerActive(). Read client-side from the player, so no sim code is asked about seats - Player.cpp is deliberately untouched, per the conventions rule against sim code reading seat state. Single-view: with one bar every apply path already ends in init(TheControlBar), so the recorded pair is by construction the pair the manager held; dock scale is 1; both m_currentScheme and m_barScheme start null and both draws no-op. The applySchemeForBarPlayer change is reached only from syncToSeats over seats 1..7 and is unreachable with one seat. --- .../Include/GameClient/ControlBar.h | 16 +++++ .../Include/GameClient/ControlBarScheme.h | 12 ++++ .../GameClient/GUI/ControlBar/ControlBar.cpp | 43 ++++++++++++- .../GUI/ControlBar/ControlBarScheme.cpp | 62 +++++++++++++++++-- .../GUI/GUICallbacks/W3DControlBar.cpp | 15 ++--- 5 files changed, 132 insertions(+), 16 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBar.h b/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBar.h index 80041681e04..233ef64810d 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBar.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBar.h @@ -54,6 +54,7 @@ class Player; class PlayerTemplate; class AudioEventRTS; class ControlBarSchemeManager; +class ControlBarScheme; class UpgradeTemplate; class ControlBarResizer; class GameWindowTransitionsHandler; @@ -1009,6 +1010,14 @@ class ControlBar : public SubsystemInterface /// Splitscreen (WP8): the rectangle this bar is currently docked to. const IRegion2D &getBarDockRect() const { return m_barDockRect; } + /** Splitscreen: the skin THIS bar was last given, and the multiplier it was given with. + Every seat's bar shares one ControlBarSchemeManager, so its m_currentScheme is only ever + the last scheme anybody applied - drawing through it meant one player's faction (or the + blank observer skin on defeat) repainted every other seat's bar. */ + ControlBarScheme *getBarScheme() const { return m_barScheme; } + const Coord2D &getBarSchemeMultiplier() const { return m_barSchemeMultiplier; } + void setBarScheme( ControlBarScheme *scheme, const Coord2D &multiplier ); + protected: Int m_seatIndex; ///< splitscreen: seat this bar belongs to (0 = the classic bar) @@ -1024,8 +1033,15 @@ class ControlBar : public SubsystemInterface /// Last logic frame this bar counted the player's beacons (see ControlBar::update). The count /// walks the whole army, so with a bar per seat it may not run every frame. UnsignedInt m_lastBeaconCountFrame; + /// Splitscreen: the skin this bar draws with, recorded when it was applied rather than read + /// from the shared manager at paint time. See getBarScheme(). + ControlBarScheme *m_barScheme; + Coord2D m_barSchemeMultiplier; /// Which player template this bar's skin was last applied for (see applySchemeForBarPlayer). const PlayerTemplate *m_schemeAppliedForTemplate; + /// Whether that player was still active when the skin was applied. A defeated seat player + /// keeps their template, so the template alone cannot latch the switch to the observer skin. + Bool m_schemeAppliedForActive; /// Which player template this bar's superweapon strip was last built for. Same reason. const PlayerTemplate *m_shortcutBarBuiltForTemplate; diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBarScheme.h b/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBarScheme.h index ea7b34eab32..c7e4c1b6443 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBarScheme.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBarScheme.h @@ -285,7 +285,19 @@ class ControlBarSchemeManager void preloadAssets( TimeOfDay timeOfDay ); ///< preload the assets + // Splitscreen: draw a scheme the CALLER names, instead of whatever m_currentScheme happens + // to hold. Every seat's ControlBar shares this one manager, so a scheme change triggered by + // one player (notably the observer skin on defeat) repainted every other seat's bar too. + // ControlBarScheme::drawForeground/drawBackground are pure reads of m_layer[], so several + // bars may safely draw the same scheme object at different offsets and scales. + void drawForegroundFor( ControlBarScheme *scheme, const Coord2D &multiplier, Real drawScale, ICoord2D offset ); + void drawBackgroundFor( ControlBarScheme *scheme, const Coord2D &multiplier, Real drawScale, ICoord2D offset ); + private: + // Splitscreen: records the scheme just applied onto the bar it was applied to, so that bar + // can later draw with it regardless of what m_currentScheme has moved on to. + void applyCurrentSchemeToTargetBar(); + ControlBarScheme *m_currentScheme; ///< the current scheme that everythign uses Coord2D m_multiplier; Real m_drawScale; ///< splitscreen: extra scale applied when the bar is docked into a viewport diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp index 0f431564d62..177cd44f553 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp @@ -981,6 +981,9 @@ ControlBar::ControlBar() m_lastIncomeShown = ~0u; m_lastBeaconCountFrame = ~0u; // never counted; frame 0 must still be able to run it m_schemeAppliedForTemplate = nullptr; + m_schemeAppliedForActive = TRUE; + m_barScheme = nullptr; + m_barSchemeMultiplier.x = m_barSchemeMultiplier.y = 1.0f; m_shortcutBarBuiltForTemplate = nullptr; m_barDockRect.lo.x = m_barDockRect.lo.y = 0; m_barDockRect.hi.x = m_barDockRect.hi.y = 0; @@ -2210,6 +2213,12 @@ void ControlBar::initInstanceWindows() //------------------------------------------------------------------------------------------------- void ControlBar::reset() { + // Splitscreen: the scheme is a borrowed pointer into the manager's list; drop it here so a + // bar cannot draw with a skin from the previous match. + m_barScheme = nullptr; + m_schemeAppliedForTemplate = nullptr; + m_schemeAppliedForActive = TRUE; + hideSpecialPowerShortcut(); // do not destroy the rally drawable, it will get destroyed with everything else during a reset m_rallyPointDrawableID = INVALID_DRAWABLE_ID; @@ -3703,11 +3712,30 @@ void ControlBar::applySchemeForBarPlayer() return; const PlayerTemplate *pt = player->getPlayerTemplate(); - if( pt == nullptr || pt == m_schemeAppliedForTemplate ) + + // A defeated player keeps their template, so the template alone cannot latch the change to + // the observer skin. Player::killPlayer only reskins for isLocalPlayer(), which is never + // true for a seat>0 player, so without this a defeated seat's bar kept its faction skin. + // Observed client-side from isPlayerActive(); no sim code is asked about seats. + const Bool active = player->isPlayerActive(); + + if( pt == nullptr || (pt == m_schemeAppliedForTemplate && active == m_schemeAppliedForActive) ) return; m_schemeAppliedForTemplate = pt; - setControlBarSchemeByPlayer( player ); + m_schemeAppliedForActive = active; + + if( active ) + { + setControlBarSchemeByPlayer( player ); + } + else + { + // by template, matching Player::killPlayer - the by-player path would resolve the skin + // from the dead player's own side, which is still their faction + setControlBarSchemeByPlayerTemplate( + ThePlayerTemplateStore->findPlayerTemplate( NAMEKEY( "FactionObserver" ) ) ); + } } //------------------------------------------------------------------------------------------------- @@ -4867,6 +4895,17 @@ void ControlBar::showSpecialPowerShortcut() } +//------------------------------------------------------------------------------------------------- +/** Splitscreen: record the skin this bar was just given, so the paint callbacks can draw with + * it instead of reading the shared manager's m_currentScheme - which is only ever whatever + * scheme was applied last, by any bar. */ +//------------------------------------------------------------------------------------------------- +void ControlBar::setBarScheme( ControlBarScheme *scheme, const Coord2D &multiplier ) +{ + m_barScheme = scheme; + m_barSchemeMultiplier = multiplier; +} + void ControlBar::hideSpecialPowerShortcut() { if(!m_specialPowerShortcutParent) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarScheme.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarScheme.cpp index 08af77233ae..b5af3ae6125 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarScheme.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarScheme.cpp @@ -970,7 +970,7 @@ void ControlBarSchemeManager::setControlBarScheme(AsciiString schemeName) } if(m_currentScheme) // Splitscreen: apply to the bar that asked for this scheme, not the global one. - m_currentScheme->init( takeApplyToBar() ); + applyCurrentSchemeToTargetBar(); } // @@ -1006,6 +1006,58 @@ void ControlBarSchemeManager::drawBackground( ICoord2D offset ) } } +//----------------------------------------------------------------------------- +/** Splitscreen: draw a named scheme at a named scale, rather than reading the manager's shared + * m_currentScheme/m_multiplier/m_drawScale. Every seat's bar shares one manager, so those + * three are whatever the LAST scheme application left behind - which is why player 1 being + * defeated repainted all eight bars with the blank observer skin. */ +//----------------------------------------------------------------------------- +void ControlBarSchemeManager::drawForegroundFor( ControlBarScheme *scheme, const Coord2D &multiplier, Real drawScale, ICoord2D offset ) +{ + if( scheme == nullptr ) + return; + + Coord2D multi; + multi.x = multiplier.x * drawScale; + multi.y = multiplier.y * drawScale; + scheme->drawForeground( multi, offset ); +} + +//----------------------------------------------------------------------------- +void ControlBarSchemeManager::drawBackgroundFor( ControlBarScheme *scheme, const Coord2D &multiplier, Real drawScale, ICoord2D offset ) +{ + if( scheme == nullptr ) + return; + + Coord2D multi; + multi.x = multiplier.x * drawScale; + multi.y = multiplier.y * drawScale; + scheme->drawBackground( multi, offset ); +} + +//----------------------------------------------------------------------------- +/** Splitscreen: record the scheme being applied ONTO the bar it is being applied to, then run + * the existing init(). The multiplier is captured verbatim rather than recomputed, because + * setControlBarScheme uses integer division while the two by-player paths cast to Real - + * recomputing here would silently change the by-name/shell path. + * + * Recorded BEFORE init(), because init() re-enters ControlBar via switchControlBarStage. */ +//----------------------------------------------------------------------------- +void ControlBarSchemeManager::applyCurrentSchemeToTargetBar() +{ + if( m_currentScheme == nullptr ) + return; + + ControlBar *target = takeApplyToBar(); + ControlBar *bar = (target != nullptr) ? target : TheControlBar; + + if( bar != nullptr ) + bar->setBarScheme( m_currentScheme, m_multiplier ); + + // init() keeps its own null -> TheControlBar fallback, so the five call sites are unchanged + m_currentScheme->init( target ); +} + //----------------------------------------------------------------------------- void ControlBarSchemeManager::setControlBarSchemeByPlayerTemplate( const PlayerTemplate *pt, Bool useSmall) { @@ -1017,7 +1069,7 @@ void ControlBarSchemeManager::setControlBarSchemeByPlayerTemplate( const PlayerT if(m_currentScheme && (m_currentScheme->m_side.compare(side) == 0)) { // Splitscreen: apply to the bar that asked for this scheme, not the global one. - m_currentScheme->init( takeApplyToBar() ); + applyCurrentSchemeToTargetBar(); DEBUG_LOG(("setControlBarSchemeByPlayer already is using %s as its side", side.str())); return; @@ -1067,7 +1119,7 @@ void ControlBarSchemeManager::setControlBarSchemeByPlayerTemplate( const PlayerT } if(m_currentScheme) // Splitscreen: apply to the bar that asked for this scheme, not the global one. - m_currentScheme->init( takeApplyToBar() ); + applyCurrentSchemeToTargetBar(); } //----------------------------------------------------------------------------- void ControlBarSchemeManager::setControlBarSchemeByPlayer(Player *p) @@ -1087,7 +1139,7 @@ void ControlBarSchemeManager::setControlBarSchemeByPlayer(Player *p) if(m_currentScheme && (m_currentScheme->m_side.compare(side) == 0)) { // Splitscreen: apply to the bar that asked for this scheme, not the global one. - m_currentScheme->init( takeApplyToBar() ); + applyCurrentSchemeToTargetBar(); DEBUG_LOG(("setControlBarSchemeByPlayer already is using %s as its side", side.str())); return; @@ -1137,7 +1189,7 @@ void ControlBarSchemeManager::setControlBarSchemeByPlayer(Player *p) } if(m_currentScheme) // Splitscreen: apply to the bar that asked for this scheme, not the global one. - m_currentScheme->init( takeApplyToBar() ); + applyCurrentSchemeToTargetBar(); } diff --git a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/GUI/GUICallbacks/W3DControlBar.cpp b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/GUI/GUICallbacks/W3DControlBar.cpp index 31da1bfd50b..6515ab85294 100644 --- a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/GUI/GUICallbacks/W3DControlBar.cpp +++ b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/GUI/GUICallbacks/W3DControlBar.cpp @@ -664,10 +664,6 @@ void W3DCommandBarBackgroundDraw( GameWindow *window, WinInstanceData *instData //win = TheWindowManager->winGetWindowFromId(nullptr,TheNameKeyGenerator->nameToKey( "ControlBar.wnd:BackgroundMarker" )); } bar->getBackgroundMarkerPos(&basePos.x, &basePos.y); - // Splitscreen: one scheme manager serves every bar, so the scale it paints at has to be set - // by the bar that is drawing rather than left at whatever the last bar to DOCK happened to - // want. Otherwise a bar could paint its faction skin at another viewport's scale. - bar->getControlBarSchemeManager()->setDrawScale( bar->getBarDockScale() ); ICoord2D pos, offset; win->winGetScreenPosition(&pos.x,&pos.y); // Splitscreen: the marker window moves AND shrinks with a docked bar, and the skin is @@ -677,7 +673,10 @@ void W3DCommandBarBackgroundDraw( GameWindow *window, WinInstanceData *instData offset.x = pos.x - (Int)(basePos.x * barScale); offset.y = pos.y - (Int)(basePos.y * barScale); - man->drawBackground(offset); + // Splitscreen: draw THIS bar's own recorded skin, at this bar's own scale. Reading the + // manager's m_currentScheme meant every bar painted whatever scheme was applied last, so + // player 1's defeat (which sets the blank observer skin) blanked all eight viewports. + man->drawBackgroundFor( bar->getBarScheme(), bar->getBarSchemeMultiplier(), bar->getBarDockScale(), offset ); } @@ -703,9 +702,6 @@ void W3DCommandBarForegroundDraw( GameWindow *window, WinInstanceData *instData //win = TheWindowManager->winGetWindowFromId(nullptr,TheNameKeyGenerator->nameToKey( "ControlBar.wnd:BackgroundMarker" )); } bar->getForegroundMarkerPos(&basePos.x, &basePos.y); - // Splitscreen: see W3DCommandBarBackgroundDraw - the shared scheme manager paints at the - // scale of whichever bar is drawing, not of whichever docked last. - bar->getControlBarSchemeManager()->setDrawScale( bar->getBarDockScale() ); ICoord2D pos, offset; win->winGetScreenPosition(&pos.x,&pos.y); // Splitscreen: the marker window moves AND shrinks with a docked bar, and the skin is @@ -715,7 +711,8 @@ void W3DCommandBarForegroundDraw( GameWindow *window, WinInstanceData *instData offset.x = pos.x - (Int)(basePos.x * barScale); offset.y = pos.y - (Int)(basePos.y * barScale); - man->drawForeground(offset); + // Splitscreen: see W3DCommandBarBackgroundDraw - this bar's own skin, this bar's own scale. + man->drawForegroundFor( bar->getBarScheme(), bar->getBarSchemeMultiplier(), bar->getBarDockScale(), offset ); } From 79adf2a5c68060254293ddc4029384898f3abe6b Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:17:38 +0300 Subject: [PATCH 05/42] splitscreen: the radar drew over the shell map after a resolution change Not the structural cause the handoff proposed. It claimed recreateControlBar rebuilds three window roots while HideControlBar can only reach one, so the radar and RightHUD were structurally unreachable. Extracting the shipped ControlBar.wnd from WindowZH.big shows exactly ONE column-0 WINDOW block, ControlBarParent; LeftHUD (the radar's DRAWCALLBACK) and RightHUD are its CHILDren. Hiding the parent hides the radar - drawWindow and isHidden both stop at a hidden ancestor. The real cause is a stale-instance resolution. recreateControlBar calls createControlBar(), whose HideControlBar() runs BEFORE 'delete TheControlBar' - so actingControlBar() still resolves the OLD bar, and ControlBar::findBarWindowById scopes strictly to that instance's roots and returns the OLD ControlBarParent. The seat-0 global fallback never runs because the scoped search succeeded. Net: the outgoing, already-hidden root is hidden again and the incoming one - authored ENABLED - stays visible. This is handoff2 5.2's bug class 1 arriving from the opposite direction: not a global lookup returning an arbitrary instance, but a scoped lookup pinned to a dead one. Before splitscreen the global winGetWindowFromId happened to land on the newest root and this worked by accident. Fixed at the end of recreateControlBar, after init() has given the new bar its roots, so both call sites (OptionsMenu and MainMenu) and any future one are covered. Deliberately did NOT take the handoff's first proposal of guarding those two call sites: window geometry is baked from the live display size at parse time and the ControlBar layout is never rebuilt at match start, so skipping the rebuild would leave the bar sized for the old resolution for the rest of the process - a quieter, worse bug. Guard text copied verbatim from the idiom already in OptionsMenu.cpp. --- .../Code/GameEngine/Source/GameClient/InGameUI.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index d79e6628e28..8151d1e81b5 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -6874,6 +6874,18 @@ void InGameUI::recreateControlBar() if( !s_controlBarLayoutRoots.empty() ) TheControlBar->setBarLayoutWindows( &s_controlBarLayoutRoots[0], (Int)s_controlBarLayoutRoots.size() ); TheControlBar->init(); + + // Splitscreen: createControlBar's own HideControlBar ran while TheControlBar was STILL the + // old instance, and ControlBar::findBarWindowById scopes strictly to that instance's roots - + // so it hid the outgoing ControlBarParent and left the one just created showing. ControlBar.wnd + // authors its root ENABLED, not HIDDEN, so after a resolution change on the main menu the + // fresh bar - radar and all - drew straight over the shell map. + // + // Hide it here instead, where the new bar owns its roots and the scoped lookup resolves them. + // Before splitscreen the global winGetWindowFromId happened to find the newest root and this + // worked by accident; this restores that net effect deliberately. + if( (TheGameLogic->isInGame() == FALSE) || (TheGameLogic->isInShellGame() == TRUE) ) + HideControlBar( TRUE ); } void InGameUI::refreshCustomUiResources() From 45077fb273268db9971683640d149875340453cc Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:20:06 +0300 Subject: [PATCH 06/42] splitscreen: a pad seat's cursor was pinned to ARROW by its own selection The handoff's diagnosis for this one does not hold. It says seat 0's mouse resting on its own HUD force-resets every other seat's cursor via the TheMouse->getMouseStatus() reads in createCommandHint/createMouseoverHint. getWindowUnderCursor is seat-aware - all three of its window loops skip any window winSeatOwnsWindow() rejects - so for seat N the lookup is essentially always null and underWindow is stuck FALSE. The claimed symptom is the inverse of what that code can produce. The dominant defect is Object::isLocallyControlled(), which compares against ThePlayerList->getLocalPlayer() - seat 0's player - and is read four times across the two functions. The decisive one gates the MOUSEMODE_DEFAULT early return: getSelectCount()/getAllSelectedDrawables() are correctly per-seat, so a seat with exactly ONE of its own units selected yields a non-null srcObj that fails isLocallyControlled(), takes setMouseCursor(ARROW) and returns. With two or more selected, srcObj is null and control falls through to the message-type switch - which predicts a distinctive signature to check against the live repro: the cursor is stuck with one unit selected but changes shape with two. All four now ask isControlledByPlayer(getCommandActingPlayer()), the pattern already used in SelectionXlat and CommandXlat. Exact identity in single view: getCommandActingPlayer() returns the local player whenever the seat override is unset, which is what isLocallyControlled() compares against. The getMouseStatus reads are still wrong and are fixed too, via a getSeatHoverPixel() helper - seat 0 reads TheMouse verbatim, a pad seat reads its own virtual cursor, which is already display-space and clamped to its viewport. Shroud gating: createCommandHint's getObservedOrLocalPlayer read is swapped unconditionally (it early-returns on playback, so the observer case is unreachable); createMouseoverHint's is seat-gated instead, because that function has NO playback guard and an unconditional swap would change replay-observer tooltips to the local player's shroud. The three OS-mouse tooltip writes are now seat-0-only - the tooltip belongs to the pointer, and a pad seat's hover was clearing and rewriting player 1's. --- .../GameEngine/Source/GameClient/InGameUI.cpp | 82 +++++++++++++++---- 1 file changed, 67 insertions(+), 15 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index 8151d1e81b5..87bc5c50393 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -2681,6 +2681,35 @@ void InGameUI::createGarrisonHint( const GameMessage *msg ) #endif // defined(RTS_DEBUG) +//------------------------------------------------------------------------------------------------- +/** Splitscreen: the pixel the given seat is hovering at. Seat 0 owns the OS pointer and keeps + * reading TheMouse; a pad seat has no OS pointer at all and must be asked for its own virtual + * cursor, which is already in display coordinates clamped to that seat's viewport. */ +//------------------------------------------------------------------------------------------------- +static Bool getSeatHoverPixel( Int seat, ICoord2D *out ) +{ +#if RTS_SDL3_ENABLE + if( seat > 0 && TheSeatManager != nullptr ) + { + const LocalSeat *s = TheSeatManager->getSeat( seat ); + if( s == nullptr || !s->m_cursor.visible ) + return FALSE; + *out = s->m_cursor.pos; + return TRUE; + } +#endif + + if( TheMouse == nullptr ) + return FALSE; + + const MouseIO *io = TheMouse->getMouseStatus(); + if( io == nullptr ) + return FALSE; + + *out = io->pos; + return TRUE; +} + //------------------------------------------------------------------------------------------------- /** Details of what is mouse hovered over right now are in this message. Terrain might result * in just a tooltip. An object might get a tooltip and show its hit points. @@ -2692,10 +2721,10 @@ void InGameUI::createMouseoverHint( const GameMessage *msg ) return; // no mouseover for you GameWindow *window = nullptr; - const MouseIO *io = TheMouse->getMouseStatus(); + ICoord2D hoverPixel; Bool underWindow = false; - if (io && TheWindowManager) - window = TheWindowManager->getWindowUnderCursor(io->pos.x, io->pos.y); + if (getSeatHoverPixel(m_activeSeat, &hoverPixel) && TheWindowManager) + window = TheWindowManager->getWindowUnderCursor(hoverPixel.x, hoverPixel.y); while (window) { @@ -2727,7 +2756,11 @@ void InGameUI::createMouseoverHint( const GameMessage *msg ) if (msg->getType() == GameMessage::MSG_MOUSEOVER_DRAWABLE_HINT) { - TheMouse->setCursorTooltip(UnicodeString::TheEmptyString ); + // Splitscreen: the tooltip belongs to the OS pointer, which only seat 0 holds. Without + // this guard a pad seat's hover clears and rewrites player 1's tooltip. Same policy the + // window translator already adopted - tooltips are reserved to the seat holding the mouse. + if( m_activeSeat == 0 ) + TheMouse->setCursorTooltip(UnicodeString::TheEmptyString ); m_seatContexts[m_activeSeat].m_mousedOverDrawableID = INVALID_DRAWABLE_ID; const Drawable *draw = TheGameClient->findDrawableByID(msg->getArgument(0)->drawableID); const Object *obj = draw ? draw->getObject() : nullptr; @@ -2878,7 +2911,15 @@ void InGameUI::createMouseoverHint( const GameMessage *msg ) else tooltip = str; - const Int localPlayerIndex = rts::getObservedOrLocalPlayer()->getPlayerIndex(); + // Splitscreen: a pad seat must gate the tooltip's shroud on ITS OWN player, not on + // the render-only helper (which answers seat 0 outside a render pass). Seat 0 keeps + // getObservedOrLocalPlayer deliberately: unlike createCommandHint this function has + // no RECORDERMODETYPE_PLAYBACK guard, so swapping it unconditionally would change + // replay-observer tooltips to use the local player's shroud instead of the observed + // player's. + const Int localPlayerIndex = (m_activeSeat > 0) + ? getCommandActingPlayer()->getPlayerIndex() + : rts::getObservedOrLocalPlayer()->getPlayerIndex(); Int x, y; ThePartitionManager->worldToCell(obj->getPosition()->x, obj->getPosition()->y, &x, &y); @@ -2913,7 +2954,9 @@ void InGameUI::createMouseoverHint( const GameMessage *msg ) //any popup box at all if that is the case! if( displayName.compare( TheGameText->fetch( "OBJECT:Prop" ) ) ) { - TheMouse->setCursorTooltip(tooltip, -1, &rgb ); + // Splitscreen: OS-pointer tooltip, seat 0 only (see the clear above) + if( m_activeSeat == 0 ) + TheMouse->setCursorTooltip(tooltip, -1, &rgb ); } } } @@ -2928,7 +2971,9 @@ void InGameUI::createMouseoverHint( const GameMessage *msg ) if (oldID != m_seatContexts[m_activeSeat].m_mousedOverDrawableID) { //DEBUG_LOG(("Resetting tooltip delay")); - TheMouse->resetTooltipDelay(); + // Splitscreen: OS-pointer tooltip timing, seat 0 only (see the writes above) + if( m_activeSeat == 0 ) + TheMouse->resetTooltipDelay(); } if (m_mouseMode == MOUSEMODE_DEFAULT && !m_isScrolling && !m_isSelecting && !getSelectCount() && (TheRecorder->getMode() != RECORDERMODETYPE_PLAYBACK || TheLookAtTranslator->hasMouseMovedRecently())) @@ -2945,7 +2990,7 @@ void InGameUI::createMouseoverHint( const GameMessage *msg ) drawSelectable = false; } - if( drawSelectable && obj->isLocallyControlled() ) + if( drawSelectable && obj->isControlledByPlayer(getCommandActingPlayer()) ) { setMouseCursor(Mouse::SELECTING); } @@ -2980,7 +3025,10 @@ void InGameUI::createCommandHint( const GameMessage *msg ) if( draw && (t == GameMessage::MSG_DO_ATTACK_OBJECT_HINT || t == GameMessage::MSG_DO_ATTACK_OBJECT_AFTER_MOVING_HINT) ) { const Object* obj = draw->getObject(); - const Int localPlayerIndex = rts::getObservedOrLocalPlayer()->getPlayerIndex(); + // Splitscreen: the acting seat's own player. Unconditional here, unlike createMouseoverHint, + // because this function already early-returns on RECORDERMODETYPE_PLAYBACK above, so the + // replay-observer case cannot reach this line. + const Int localPlayerIndex = getCommandActingPlayer()->getPlayerIndex(); #if ENABLE_CONFIGURABLE_SHROUD ObjectShroudStatus ss = (!obj || !TheGlobalData->m_shroudOn) ? OBJECTSHROUD_CLEAR : obj->getShroudedStatus(localPlayerIndex); #else @@ -3013,10 +3061,10 @@ void InGameUI::createCommandHint( const GameMessage *msg ) // set cursor to normal if there is a window under the cursor GameWindow *window = nullptr; - const MouseIO *io = TheMouse->getMouseStatus(); + ICoord2D hoverPixel; Bool underWindow = false; - if (io && TheWindowManager) - window = TheWindowManager->getWindowUnderCursor(io->pos.x, io->pos.y); + if (getSeatHoverPixel(m_activeSeat, &hoverPixel) && TheWindowManager) + window = TheWindowManager->getWindowUnderCursor(hoverPixel.x, hoverPixel.y); while (window) @@ -3057,7 +3105,11 @@ void InGameUI::createCommandHint( const GameMessage *msg ) case MOUSEMODE_DEFAULT: { // This section of code only gets called when there is no specific cursor mode happening. - if (underWindow || (srcObj && !srcObj->isLocallyControlled())) + // Splitscreen: isLocallyControlled() compares against ThePlayerList's local player, + // i.e. seat 0's, so a pad seat with exactly one of ITS OWN units selected failed this + // test and had its cursor pinned to ARROW for as long as that selection lasted. Ask + // the acting seat's player instead. Identity in single view. + if (underWindow || (srcObj && !srcObj->isControlledByPlayer(getCommandActingPlayer()))) { setMouseCursor(Mouse::ARROW); return; @@ -3066,9 +3118,9 @@ void InGameUI::createCommandHint( const GameMessage *msg ) { case GameMessage::MSG_DO_MOVETO_HINT: { - if( !drawSelectable && srcObj && srcObj->isLocallyControlled() && srcObj->isKindOf(KINDOF_STRUCTURE)) + if( !drawSelectable && srcObj && srcObj->isControlledByPlayer(getCommandActingPlayer()) && srcObj->isKindOf(KINDOF_STRUCTURE)) setMouseCursor( Mouse::GENERIC_INVALID ); - else if( drawSelectable && obj->isLocallyControlled() && !obj->isKindOf(KINDOF_MINE)) + else if( drawSelectable && obj->isControlledByPlayer(getCommandActingPlayer()) && !obj->isKindOf(KINDOF_MINE)) setMouseCursor( Mouse::SELECTING ); else if( TheRadar->isRadarWindow( window ) && !rts::localPlayerHasRadar() ) setMouseCursor( Mouse::ARROW ); From 8b558a2c239d6e9088bd333fb8d7383022b9a998 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:22:30 +0300 Subject: [PATCH 07/42] docs: handoff4 - verification round, 1 refuted, 6 partly wrong, 6 fixes landed Records what the pre-implementation verification sweep found, because the refutations are worth more than the fixes and will be re-derived otherwise. The headline: handoff3's 12 open findings had accurate file:line citations and inaccurate reasoning about them. #8's prescribed fix is already in the tree and its stated mechanism is architecturally impossible; #2/#3's would have written only entry [0] of a MAX_SEATS array; #9's would have left a pad seat unable to place anything at all; #10's and #13's named causes cannot produce the reported symptoms; #11 is partly dead code behind a static that is never assigned. Also records the build recipe that actually works (VS-bundled cmake, not whatever is on PATH), three SSH/PowerShell failure modes that exit 0 while doing nothing, six new findings nobody had written down, and the probe #8 needs before it can be diagnosed at all - the existing input log is structurally blind to click messages. --- PatchNotes/splitscreen-bugfix-handoff4.md | 302 ++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 PatchNotes/splitscreen-bugfix-handoff4.md diff --git a/PatchNotes/splitscreen-bugfix-handoff4.md b/PatchNotes/splitscreen-bugfix-handoff4.md new file mode 100644 index 00000000000..0448c56f431 --- /dev/null +++ b/PatchNotes/splitscreen-bugfix-handoff4.md @@ -0,0 +1,302 @@ +# Splitscreen bug sweep — handoff #4 (2026-08-06) + +Successor to `splitscreen-bugfix-handoff3.md`. That file listed 13 findings with 1 committed +and 12 open. This round **re-verified all 12 against the tree before writing any code**, using +11 parallel read-only agents briefed to *refute* rather than confirm. That was worth doing: + +> **4 confirmed as written, 6 partly wrong, 1 refuted outright.** +> Seven of eleven prescribed fix shapes would have shipped a no-op, a regression, or a fix for +> a cause that does not exist. + +**Do not skip the verification step in the next round either.** Every wrong diagnosis in +handoff3 was written with confident file:line citations, and the citations were *accurate* — +it was the reasoning about them that was wrong. Accurate citations are not evidence of a +correct diagnosis. + +## 1. Where things stand + +Branch `splitscreen-documents`. Six fixes landed this round, each compiled and relink-verified +on a Windows host (Release win32 x86, VS 2022 BuildTools, MSVC 14.44): + +| finding | commit | what actually landed | +|---|---|---| +| #7 lasso | `66e2f73b7` | + the latent stuck-lasso bug drawing it exposed | +| #5 observer HQ | `7c67ce432` | via a new `getCommandingSeatIndexForPlayer()` | +| #9 placement | `d14e21c39` | clear-only sites; arm/consume pair deliberately deferred | +| #4 bar scheme | `ce879e9b5` | per-bar recorded scheme + defeated-seat observer skin | +| #13 shell radar | `79adf2a5c` | stale-instance resolution, not the claimed root count | +| #10 cursor shape | `45077fb27` | real cause was `isLocallyControlled`, not `TheMouse` | + +Baseline exe SHA256 before any change: `1C25A9BE5518C472943E860558AE8C4FCCC943875CF6AED48362C2F37BD38362`. +After the six: `BD35E6BE7A851DDE8ECABD0B223485E0B91CAF8C8A97F79A8B6ECD82338EF1ED`. +**The exe size never changed** (9,159,168 bytes at every step) — gate on the SHA, never the size. + +**Nothing in this round is runtime-verified.** Every fix is a static argument plus a clean +compile. Four findings remain open, below. + +## 2. Build recipe that actually works + +The handoff3 recipe is right but omits the trap that cost the most time here. + +``` +Enter-VsDevShell -DevCmdArguments '-arch=x86 -host_arch=x64' +cmake --preset win32 # Ninja Multi-Config, non-vcpkg +ninja -f build-Release.ninja z_generals # from build/win32 +``` + +* **Use the cmake/ninja that ship with VS BuildTools**, not whatever is on `PATH`. + A winlibs cmake 4.3.2 on `PATH` fails configure: it is built against OpenSSL with no default + CA bundle, so the SDL3 `FetchContent` download dies with cURL **status 60**. It ignores + `CURL_CA_BUNDLE`; only `CMAKE_TLS_CAINFO` works. VS's cmake 3.31 downloads it with no config. + This is **not** a certificate problem on the box — `git` works because it uses schannel, and + the box sees a genuine `github.com` → Sectigo chain. + cmake 4.x also drops `cmake_minimum_required` < 3.5, which this codebase will trip over. +* The target is `z_generals`; the **output is `generalszh.exe`**, not `z_generals.exe`. +* `GeneralsReplays` submodule does not need initialising to build. + +### Driving it over SSH + +* The default shell is PowerShell. `ssh $WIN 'powershell -Command -' < script.ps1` feeds the + script **line by line**, so any multi-line `if {}` block is split into fragments that do + nothing — **it exits 0 and prints nothing**, indistinguishable from success. A clone + "succeeded" that way with no repo on disk. Use base64 `-EncodedCommand`. +* Do **not** set `$ErrorActionPreference='Stop'` around native git. git writes ordinary progress + ("Already on 'splitscreen-documents'") to stderr and PowerShell promotes that to a terminating + error. Gate on `$LASTEXITCODE`. +* Commit identity: a clone with an `https://` remote does **not** match the + `includeIf hasconfig:remote.*.url:git@github-personal:*/*` rule and silently falls back to the + global default account. Set `user.name`/`user.email` locally in the clone before committing. + +## 3. The refutation — #8 is not what handoff3 says it is + +**#8 (gamepad can only select via drag, not a direct click) — REFUTED. The prescribed fix is +already in the tree.** + +handoff3 says `pickDrawable`'s `getWindowUnderCursor` check "currently has none beyond 'any +window blocks'". That is factually wrong: + +* `GameWindowManager.cpp:3863`, `:3888`, `:3913` each do `if (!winSeatOwnsWindow(window)) continue;` +* the function's own doc comment (`:3818-3829`) names `View::pickDrawable` and describes + handoff3's exact hypothesis as **the bug it was added to kill** +* `MessageStream.cpp:1265-1266` calls `winBeginSeatInput(seatIdx)` before *every* translator, + deliberately widened from WindowXlat-only, with a comment saying so because "those call + `View::pickDrawable`". So `m_inputSeat >= 0` during the pick and the `< 0` bypass never fires. + +Consequence: for `m_inputSeat > 0`, `winSeatOwnsWindow` returns FALSE for any window owned by no +bar or by a *different* seat's bar — and FALSE means `continue`, i.e. **does not block**. "A +stray fragment of another seat's oversized control bar" is architecturally impossible, not +merely unlikely. + +The named alternative (handoff2 §5.3, `SelectionTranslator`'s own fields) does not explain it +either — the whole `MSG_MOUSE_LEFT_CLICK` case reads none of `m_leftMouseButtonIsDown`, +`m_selectFeedbackAnchor`, `m_dragSelecting`, `m_deselectFeedbackAnchor`, `m_lastClick`. The +click region comes from MetaEvent's per-seat `m_mouseDownPosition[seat][index]`. + +**Best surviving hypothesis:** `getWindowUnderCursor` has three early returns *before* any seat +filter — `m_mouseCaptor` (`:3833`), `m_grabWindow` (`:3839`), `m_modalHead` (`:3846`). The first +two are swapped per seat by `winBeginSeatInput`. A stale `m_seatGrabWindow[padSeat]` left over +from a bar button press would make every later pick by **that one seat** return the grab window +and refuse — permanently, and for that seat only. That matches "click dead, drag alive, one +seat" exactly. Neither handoff3 nor the overlay mentions it. + +**The verification handoff3 asks for cannot be performed.** It says to check +`splitscreen_input.log`, but the translator trace (`MessageStream.cpp:1278-1286`) filters to +`msg->getType() >= MSG_BEGIN_META_MESSAGES` (=177) while `MSG_MOUSE_LEFT_CLICK` is **163** — the +log is structurally blind to clicks. The seat overlay only reports `g_dbgLastClickSeat`. +**A probe has to be added first.** Per cooked left click from the pad seat, log: the seat tag and +`getCommandActingSeat()`; `isPoint` and the pixelRegion; whether `pickDrawable` returned null; +if null, whether `getWindowUnderCursor` returned non-null and **which exit produced it**, that +window's id/region, and `m_inputSeat`; and `drawablesThatWillSelect.size()`. +Readings: owned by the acting seat's own bar → the narrowed window theory; via the `m_grabWindow` +early return → the stale grab; both null → the ray-cast itself missed; `isPoint` FALSE → the +finding is misfiled entirely. + +## 4. Findings whose prescribed fix was wrong + +### #2/#3 splash — the prescribed fix ships a no-op. STILL OPEN. +handoff3 says to resolve `rts::getSeatIndexForPlayer()` "for the player the script action +concerns" and make `m_messageWindow[MAX_SEATS]`. **There is no such player, and the array would +only ever have `[0]` written.** + +* MP victory/defeat scripts are appended to **one** side's list — `GameLogic.cpp:1607`, + `TheSidesList->getSideInfo(0)->getScriptList()`. Not per side. +* Their conditions resolve through `m_localSlotNum` (`ScriptConditions.cpp:1752/1760/1768` → + `VictoryConditions.cpp:400-424`, assigned at `:382-383` from `isLocalPlayer()`). +* So `doVictory`/`doDefeat`/`doLocalDefeat` fire **at most once per match, for seat 0 only**. + Seats 1..7 never reach `ScriptActions` at all. +* `TheScriptEngine->getCurrentPlayer()` *is* live during `executeAction`, but because of the + above it is side 0's player — threading it in would look correct and mislabel every splash. +* `GameLogic.cpp:1626-1686` has a commented-out block that would have built these per side. It + also targeted `getSideInfo(0)`. **Do not resurrect it.** + +This needs **two** deliverables: (a) reposition seat 0's existing splash into its own viewport — +pure positioning, fixes "the popup covers everybody's screen"; (b) a **new trigger** in +`VictoryConditions::update()` for seats 1..7, which is the one place that already detects defeat +per player (`:199-240`) and victory per alliance (`:184-196`), and which `c2a26a4e8` already +wired to `rts::getSeatIndexForPlayer` at `:213`. Karl scoped this to include (b). + +Mechanism notes handoff3 omits: `winCreateFromScript` returns only the **first** top-level +window, so `closeWindows` already destroys only one root and a multi-root `.wnd` leaks today — +the reposition helper must iterate `WindowLayoutInfo::windows`, not assume one root. The `.wnd` +files themselves are **not in the repo** (they live in the install's `Window\` tree), so root +count cannot be verified statically. `TheRecorder->isMultiplayer()` is TRUE for skirmish, so +`VictoryConditions::update()`'s early-out at `:180` does not block the harness — verified, +because the whole plan depends on it. + +### #9 — the prescribed fix would have made it worse. PARTLY LANDED. +handoff3 says "change all of these call sites to pass `m_seatIndex`", lumping the **arm** sites +in with the **clear** site. They are not equivalent. The whole legacy placement accessor family +forwards to a literal 0 (`InGameUI.cpp:3594,3608,3625,3652,3667,3685,3705`) — note this differs +from the *selection* family, which uses `m_activeSeat`. Because arm **and** consume are both +pinned to 0, seat N's build currently completes, wrongly, through seat 0's context. Moving only +the arm side leaves `PlaceEventTranslator` reading seat 0, so `getPendingPlaceType()` returns +nullptr and **seat N can no longer place anything at all**. + +Landed: the three *clear-only* sites — `ControlBar.cpp:2585` (the reported bug), +`ControlBarCommandProcessing.cpp:197` and `CommandXlat.cpp:3909`, the latter two **missed by +handoff3**. The site list is **fifteen** 2-arg occurrences in GeneralsMD, not four. + +**Still open — the arm/consume pair.** Needs `ControlBarCommandProcessing.cpp:266,311,353` +*together with* routing every placement read/write in `PlaceEventTranslator` through +`msg->getSeatIndex()`, **and** the three `TheTacticalView->screenToTerrain` calls at `:83,:179,:277` +through the acting seat's view — otherwise seat N's pixels are projected through seat 0's camera +and buildings land in the wrong world position. + +### #10 — the stated symptom cannot happen. LANDED, different cause. +See `45077fb27`. The `getWindowUnderCursor` seat filter means seat N's lookup is essentially +always null — the **inverse** of handoff3's claim. Real cause: `Object::isLocallyControlled()` +read 4× across the two hover functions. **Live check:** cursor stuck on ARROW with exactly one +unit selected, but changing shape with two or more, confirms it. + +### #13 — the structural claim is refuted. LANDED, different cause. +handoff3 says `recreateControlBar` rebuilds three window roots that `HideControlBar` cannot +reach. Extracting the shipped `ControlBar.wnd` from `WindowZH.big` shows **one** column-0 +`WINDOW` block, `ControlBarParent`; `LeftHUD` (the radar's `DRAWCALLBACK`) and `RightHUD` are its +**children**. Hiding the parent hides the radar. Real cause: `createControlBar`'s +`HideControlBar()` runs while `TheControlBar` is still the **old** bar, and +`findBarWindowById` scopes strictly to that instance — so it hides the outgoing root and leaves +the incoming one, authored ENABLED, visible. Bug class 1 from the opposite direction: not a +global lookup returning an arbitrary instance, but a **scoped lookup pinned to a dead one**. + +### #11 — partly dead code. STILL OPEN. +`static Bool useAnimation = FALSE;` (`ControlBarPopupDescription.cpp:101`) is never assigned +anywhere — unbounded grep returns reads only. The sole +`theAnimateWindowManager = NEW AnimateWindowManager` sits behind `if (useAnimation && ...)`, so +the pointer is permanently null and **there is no slide-in to bound**. This is the +`RETAIL_COMPATIBLE_CRC` pattern: a fix there ships nothing while still forcing an atomic +redeploy. Drop that sub-claim. + +Also: **the stated trigger is wrong.** `GameWindowManager.cpp:1302` gates the whole +tooltip-callback path on `ownsSharedMouse = (m_inputSeat <= 0)`, and a pad seat runs with +`m_inputSeat >= 1`, so a gamepad seat **never** fires `commandButtonTooltip`. The live repro is +player 1 moving the OS mouse across another seat's bar. Reproduce it that way. + +And `'a correctly-scoped basePos (via findBarWindow)'` is wrong — `getBackgroundMarkerPos()` +returns an **authored** coordinate captured once at init, mixed at `:663-664` against a **docked** +`winGetScreenPosition()`. `W3DControlBar.cpp:674-678` already carries the correction. Two +defects, not one. + +Three further mechanisms required for coherence that handoff3 does not enumerate: +`ControlBarPopupDescription.cpp:249` reads `ThePlayerList->getLocalPlayer()` (line 570 of the +same function already uses the per-instance accessor); six sibling-walking `winGetWindowFromId` +lookups at `:560/:565/:582/:594/:600/:614`; and `ControlBarPopupDescriptionUpdateFunc` +(`:102-125`) drives the **global** `TheControlBar` while installed on every instance's layout — +dormant only because no seat>0 layout is ever shown, and **made live by the routing fix itself**. +Fix the routing without it and seat N's popup stays on screen permanently. + +### #6 — correct, but widening the gate creates two new bugs. STILL OPEN, scoped narrow. +The fix shape is right (`getSeatIndexForPlayer`, not the `isLocallyViewed()` that handoff3's +point (a) suggests — that helper is render-only-safe and answers player 1 outside a render pass, +so copying it reproduces the bug being fixed; **delete that sentence from handoff3**). + +Karl's call: **land narrow, log the rest.** The two consequences, both real: + +1. **Cross-seat suppression.** `Radar::tryEvent` (`:1166-1215`) dedups against all events of the + same type with no owner concept, and with `PRESERVE_RADAR_WARNING_SUPPRESSION` (=1) the + suppression is **map-wide for 10 seconds**. Once every seat can raise the event, seat 0 being + attacked silently swallows seat 3's warning. In an 8-seat game under fire, most seats get no + warning — arguably worse than today's "only player 1 gets it". +2. **Radar blips leak across viewports.** `W3DRadar::drawEvents` draws every entry in the shared + `m_event[]` into whichever radar is painting, with no owner filter. The "jump to last radar + event" hotkey points every seat at the most recent event on the machine. + +Fixing either properly wants an owner on `RadarEvent`, which is inside Radar's xfer chain +(`Radar.cpp:1436`) — conventions say stop and ask. Non-xfer alternative: a parallel client-only +`Int m_eventOwnerSeat[MAX_RADAR_EVENTS]`, cleared in `reset()`, never serialized. + +Two more things handoff3 misses: a **fourth** `TheInGameUI->message()` at `Radar.cpp:1111` (the +cited range `1043-1109` stops 13 lines short of the real end at 1122), and +`TheControlBar->triggerRadarAttackGlow()` at `:1057` flashing **seat 0's** radar frame for every +seat's attack. Also `"isLocalPlayer() a third time at :1093"` is overstated — that test is +unconditionally TRUE today because the only caller's gate guarantees it, so the branch is dead; +it is latent, made live by step 1. + +**Build-target note (pre-existing, not caused by any of this):** `Radar.cpp` compiles into +**both** game targets, but `messageForSeat`/`ControlBarInstances` exist only in GeneralsMD. The +`Generals/` target is **already unbuildable on this branch** — `Core/.../SelectionInfo.cpp:105-118` +calls `getCommandActingPlayer()` and `Object::isControlledByPlayer()`, both declared only under +`GeneralsMD/`. Build the ZH target only. Do **not** "fix" it with an `#ifdef`. + +## 5. New findings, none of which are in handoff3 + +* **Placement icons leak across seats.** `InGameUI::~InGameUI` (`:1348`) and `InGameUI::reset` + (`:2249`) call the 2-arg `placeBuildAvailable`, clearing seat 0 only. No seat is "acting" + during teardown; both should loop all `MAX_SEATS` — `destroyPlacementIcons(Int seat = 0)` + already exists. Today seats 1..7 leak their placement icon drawables across a match boundary. +* **`setGUICommand` is not seat-aware at all.** `ControlBar::onDrawableDeselected` (`:2577`) and + `onDrawableSelected` call `TheInGameUI->setGUICommand(nullptr)`; `m_pendingGUICommand` + (`InGameUI.h:842`) is a flat non-seat member with **no** seat-aware overload, and the body also + writes the shared `m_mouseMode`. So seat N selecting still cancels seat 0's pending GUI + command. Needs a `SeatUIContext` field + overload — Pattern B, same as `c2a26a4e8`. +* **`ControlBar.wnd` tree leaks on every resolution change.** `InGameUI.cpp:6841` looks up + `"ControlBar.wnd"`, but **no window is ever named that** — every name is decorated + `ControlBar.wnd:`. The lookup returns nullptr and `deleteInstance` no-ops, so after + *k* resolution changes there are *k+1* `ControlBarParent`s in `m_windowList`. Benign today only + because global name lookups hit the newest head-inserted copy — which is exactly the ground + that makes splitscreen name lookups ambiguous. **Do not fold the destroy into a fix:** + `Radar::m_radarWindow` caches the old LeftHUD by global lookup at map load, and + `ControlBarScheme`/`GameWindowTransitions` hold similar pointers, so destroying the old roots + turns stale pointers into dangling ones. Wants its own change that re-resolves those caches. +* **`m_isScrolling`/`m_isSelecting`/`m_mouseMode`/`m_mouseModeCursor`/`m_pendingGUICommand` are + single-instance** (`InGameUI.h:998-1001, :842`), not `SeatUIContext` fields. Seat 0 + right-drag-scrolling or lasso-dragging suppresses hint generation for **every** seat, and seat + 0's mouse mode selects the branch every seat takes. +* **`SelectionTranslator`'s raw-button cases have no seat guard at all** + (`SelectionXlat.cpp:915-919`, `:928`), so seat 0 pressing while a pad holds its button steals + `m_dragSeat` and `m_selectFeedbackAnchor`. Partially mitigated by the hand-back added in + `66e2f73b7`, but the fields themselves are still shared. +* **`getSeatIndexForPlayer`'s doc comment lied** — it said "commands", the body has always meant + "watches" (observers included). Corrected in `7c67ce432`, which also adds + `getCommandingSeatIndexForPlayer` for the ownership sense. **Pick deliberately**: the defeat + broadcast at `VictoryConditions.cpp:213` wants the watching form and would silently regress + under a global change. + +## 6. Suggested order for the next round + +1. **#8 probe first** (it is instrumentation, not a fix, and everything else about #8 is guessing + until it lands). Karl's call: add the probe, leave the fix. +2. **#2/#3** (a) reposition, then (b) the new per-seat trigger. +3. **#11**, remembering the routing fix requires the update-func fix in the same change. +4. **#1** — the only fully-confirmed finding still unimplemented. Note the extra defects the + sweep found: a **third** file static at `Diplomacy.cpp:91` + (`theAnimateWindowManager`); five global `winGetWindowFromId` lookups at `:220-227`; and + `winSeatOwnsWindow`'s own comment (`GameWindowManager.cpp:264-266`) explicitly keeps diplomacy + with seat 0 — so positioning the popup is **not enough**, a seat>0 could not press a button in + it. That is why the fix must register the popup with the seat's `ControlBar` + (`addBarLayoutWindows` + `redockAfterRootsChanged`), which is the only established mechanism + for putting a non-`ControlBar.wnd` popup in a seat viewport — the generals screen and the + special-power shortcut bar are the precedents. Also: `Diplomacy.cpp:63-85` is **not** + homogeneous — `:63-70` and `:73-75` are `NameKeyType`s, identical for every instance, and must + **not** become `[MAX_SEATS]`. +5. **#9** arm/consume pair, **#6** radar-event ownership — both need a decision first. + +## 7. Process notes + +* `getCommandActingSeat()` is at **global scope**, not in namespace `rts`. Writing + `rts::getCommandActingSeat()` will not compile. Only `getSeatIndexForPlayer` / + `getObservedOrLocalPlayer` are in `rts`. +* Line numbers in handoff3 had drifted by ~19 in `InGameUI.cpp` by the third fix of this round. + Re-grep for the symbol; never trust a cached line number more than one fix in. +* `grep -c` exits 0 when it finds matches. zsh does not word-split unquoted parameters and + aborts on unquoted globs like `--include=*.cpp` — the grep never runs and the empty output + reads as "no matches". From 6a0da01a653e305508fe03ea2ec4713061731aa6 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:54:46 +0300 Subject: [PATCH 08/42] docs: handoff4 - runtime attempt, fixes exonerated, two measurement traps A fresh build of this branch does not start on the test box, and the BASELINE commit f72603e6c crashes identically with none of this round's fixes in it - which is what clears them. Both stdout and stderr are 0 bytes and no DXVK log appears, so it dies before the graphics device or any engine logging; the pre-existing GeneralsX binary emits ~156KB and a DXVK log on the same box with the same env and data. Records two measurement traps that each produced a confidently wrong answer before being caught. Get-Process is not a liveness test here - a crashed instance stays alive holding the Technical Difficulties modal, which scored crashes as successes and manufactured a non-monotonic seat-count 'boundary' and a bogus conclusion that -splitscreendev was to blame. And launching over plain SSH always dies 0xC0000005 whatever the binary, so an SSH-vs-task comparison is not an A/B at all. Ground truth is ReleaseCrashInfo.txt's mtime against the run start. Also root-causes the STATUS_DLL_NOT_FOUND seen when relocating the exe: the build links against its own binkw32/mss32 under _deps, which differ by hash from the ones in the existing run dir. --- PatchNotes/splitscreen-bugfix-handoff4.md | 51 +++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/PatchNotes/splitscreen-bugfix-handoff4.md b/PatchNotes/splitscreen-bugfix-handoff4.md index 0448c56f431..0ed7e7f105a 100644 --- a/PatchNotes/splitscreen-bugfix-handoff4.md +++ b/PatchNotes/splitscreen-bugfix-handoff4.md @@ -290,6 +290,57 @@ calls `getCommandActingPlayer()` and `Object::isControlledByPlayer()`, both decl **not** become `[MAX_SEATS]`. 5. **#9** arm/consume pair, **#6** radar-event ownership — both need a decision first. +## 6b. Runtime verification — attempted, and what it found + +**A fresh build of this branch does not run on the test box at all, and this is NOT caused by +any fix in this round.** Established by A/B, not assumption: + +| binary | flags | verdict | +|---|---|---| +| fresh build, **baseline `f72603e6c`** (no fixes) | `-win` | **CRASHED** | +| fresh build, baseline | `-win -splitscreendev 1` | **CRASHED** | +| fresh build, HEAD (all six fixes) | `-win -splitscreendev 7` | **CRASHED** | +| pre-existing `GeneralsX-run\generalszh.exe` | `-win` | **RUNNING, healthy** | + +The baseline crashing identically is what clears the six fixes. Crash record: + +``` +Release Crash at +; Reason Uncaught Exception during initialization. +``` +Empty stack. **stdout and stderr are both 0 bytes, and no DXVK log is produced** — so it dies +before the graphics device or any engine logging is up. The working binary emits ~156 KB of +`[GX-ISSUE144]` font/tooltip logging and a DXVK log on the same box, same env, same data. + +### Two measurement traps that produced wrong answers first — do not repeat them + +1. **`Get-Process generalszh` is NOT a liveness test.** A crashed instance keeps its process + alive while the "Technical Difficulties" modal is up. Scoring on process existence produced a + completely bogus non-monotonic result (`seats=1` ok, `3` crash, `5` ok, `6` ok, `7` crash) and + a wrong conclusion that the `-splitscreendev` flag was to blame. With a correct detector every + count crashes, and so does no-flag. **Ground truth is the mtime of + `Documents\Command and Conquer Generals Zero Hour Data\ReleaseCrashInfo.txt`** compared against + the run start time. +2. **Launching over plain SSH always fails with `0xC0000005`** regardless of the binary — Miles + opens the audio device even headless. Every run must go through an Interactive scheduled task + (`GXSplit`, modelled on the existing `GXPlay`). An SSH-vs-task comparison is not an A/B. + +### Also root-caused: `STATUS_DLL_NOT_FOUND` when relocating the exe +The build links against **its own** `binkw32.dll` / `mss32.dll`, staged under +`build/win32/_deps/{bink,miles}-build/Release/`. These differ by hash from the copies in the +existing run directory. Dropping the new exe beside the mismatched ones gives `0xC0000135` with +0 bytes of stderr. A run directory for a fresh build must take those two DLLs from that build. +(`C:\dev\GenSplit-run` and `C:\dev\GenSplit-base-run` are set up correctly this way.) + +### What is still unknown +Why a fresh build dies that early. Not yet ruled out: a data/asset expectation this box does not +satisfy, or a build-configuration difference from however the branch author builds. Note the +working binary is from a *different fork* (GeneralsX), so it is not evidence that this branch has +ever run here. **No debugger is installed** — there is no `cdb.exe` under Windows Kits — so no +stack could be obtained. Next step is either installing the Debugging Tools for Windows and +catching the exception with a `.pdb` (one is produced next to the exe), or getting the branch +author's working run-directory layout and comparing. + ## 7. Process notes * `getCommandActingSeat()` is at **global scope**, not in namespace `rts`. Writing From f06510e91deb876b2fd39a5b0c4f5e28144e27c7 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:04:38 +0300 Subject: [PATCH 09/42] splitscreen: under-attack warnings only ever fired for player 1 Two stacked defects. Object::attemptDamage gated the radar event on isLocallyControlled(), which compares against ThePlayerList's local player, so the event never fired at all for a seat>0 unit. And inside Radar::tryUnderAttackEvent every consumer resolved seat 0: the message feed, the radar frame glow, and the EVA branch. The gate now asks getSeatIndexForPlayer(...) >= 0 - does ANY local seat command this player - kept last in the && chain so the seat scan is only reached after m_radarData != nullptr has culled most objects. Deliberately NOT done by changing Object::isLocallyControlled() (28 callers) nor by adding an isLocallyControlledByAnySeat() to Object's public API, where sim code could reach for a seat concept. Inside tryUnderAttackEvent the concerned player is resolved once, and all four message() calls route through messageForSeat() - the handoff names only three; the fourth is the generic 'RADAR:UnderAttack' else branch, and without it the default case still lands in seat 0's viewport. triggerRadarAttackGlow now flashes the concerned seat's own bar (ControlBarInstances::get returns nullptr for an unregistered seat, unlike fromWindow, so the fallback to TheControlBar is mandatory) - that site is not in the handoff either, and without it the wrong radar keeps blinking. The EVA test, which was a third instance of isLocalPlayer() and is dead today because the old gate guaranteed it true, becomes live and correct. DELIBERATELY NOT FIXED, logged in handoff4 instead: widening the gate means every seat can now raise a radar event, and tryEvent dedups map-wide for 10s under PRESERVE_RADAR_WARNING_SUPPRESSION - so seat 0 being attacked can swallow another seat's warning. And W3DRadar::drawEvents has no owner filter, so blips leak across viewports. Both want an owner on RadarEvent, which sits in Radar's xfer chain; the conventions say stop and ask before touching that. --- .../GameEngine/Source/Common/System/Radar.cpp | 28 ++++++++++++++----- .../Source/GameLogic/Object/Object.cpp | 6 +++- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/Core/GameEngine/Source/Common/System/Radar.cpp b/Core/GameEngine/Source/Common/System/Radar.cpp index fb2eafa81f6..23cb4c718a8 100644 --- a/Core/GameEngine/Source/Common/System/Radar.cpp +++ b/Core/GameEngine/Source/Common/System/Radar.cpp @@ -1054,14 +1054,26 @@ void Radar::tryUnderAttackEvent( const Object *obj ) if( eventCreated ) { - TheControlBar->triggerRadarAttackGlow(); // ///@todo Should make an INI data driven table for radar event strings, and audio events // // UI feedback for being under attack (note that we display these messages and audio // queues even if we don't have a radar) // - Player *player = rts::getObservedOrLocalPlayer(); + // Splitscreen: resolve WHOSE attack this is once. getObservedOrLocalPlayer() is the + // render-only-safe helper and always answers player 1 outside a render pass - and this + // runs in the logic - so every message and every radar flash landed on seat 0. + Player *concerned = obj->getControllingPlayer(); + const Int concernedSeat = rts::getSeatIndexForPlayer( concerned ? concerned->getPlayerIndex() : -1 ); + const Int seat = (concernedSeat >= 0) ? concernedSeat : 0; + Player *player = concerned ? concerned : rts::getObservedOrLocalPlayer(); + + // flash the concerned seat's own radar frame, not always seat 0's. ControlBarInstances::get + // returns nullptr for an unregistered seat (unlike fromWindow it does not fall back). + ControlBar *attackBar = ControlBarInstances::get( seat ); + if( attackBar == nullptr ) + attackBar = TheControlBar; + attackBar->triggerRadarAttackGlow(); // create a message for the attack event if( obj->isKindOf( KINDOF_INFANTRY ) || obj->isKindOf( KINDOF_VEHICLE ) ) @@ -1070,7 +1082,7 @@ void Radar::tryUnderAttackEvent( const Object *obj ) if( obj->isKindOf(KINDOF_HARVESTER) ) { // display special message - TheInGameUI->message( "RADAR:HarvesterUnderAttack" ); + TheInGameUI->messageForSeat( seat, "RADAR:HarvesterUnderAttack" ); // play special audio event unitAttackSound = TheAudio->getMiscAudio()->m_radarHarvesterUnderAttackSound; @@ -1078,7 +1090,7 @@ void Radar::tryUnderAttackEvent( const Object *obj ) else { // display message - TheInGameUI->message( "RADAR:UnitUnderAttack" ); + TheInGameUI->messageForSeat( seat, "RADAR:UnitUnderAttack" ); // play audio event unitAttackSound = TheAudio->getMiscAudio()->m_radarStructureUnderAttackSound; @@ -1090,13 +1102,15 @@ void Radar::tryUnderAttackEvent( const Object *obj ) else if( obj->isKindOf( KINDOF_STRUCTURE ) && obj->isKindOf( KINDOF_MP_COUNT_FOR_VICTORY ) ) { // play EVA. If its our object, play Base under attack. - if (obj->getControllingPlayer()->isLocalPlayer()) + // Splitscreen: "is it one of OURS, at this machine" - isLocalPlayer() only ever + // answered for seat 0's player. Latent until the gate above was widened. + if (concernedSeat >= 0) TheEva->setShouldPlay(EVA_BaseUnderAttack); else if (player->getRelationship(obj->getTeam()) == ALLIES) TheEva->setShouldPlay(EVA_AllyUnderAttack); // display message - TheInGameUI->message( "RADAR:StructureUnderAttack" ); + TheInGameUI->messageForSeat( seat, "RADAR:StructureUnderAttack" ); // play audio event static AudioEventRTS structureAttackSound = TheAudio->getMiscAudio()->m_radarStructureUnderAttackSound; @@ -1108,7 +1122,7 @@ void Radar::tryUnderAttackEvent( const Object *obj ) { // display message - TheInGameUI->message( "RADAR:UnderAttack" ); + TheInGameUI->messageForSeat( seat, "RADAR:UnderAttack" ); // play audio event static AudioEventRTS underAttackSound = TheAudio->getMiscAudio()->m_radarStructureUnderAttackSound; diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp index f62affdd07b..9570c699448 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Object.cpp @@ -1948,7 +1948,11 @@ void Object::attemptDamage( DamageInfo *damageInfo ) getControllingPlayer() && !BitIsSet(damageInfo->in.m_sourcePlayerMask, getControllingPlayer()->getPlayerMask()) && m_radarData != nullptr && - isLocallyControlled() ) + // Splitscreen: ask whether ANY local seat commands this player, not only whether + // seat 0 does. isLocallyControlled() compares against ThePlayerList's local player, + // so the under-attack event never even fired for seats 1..7. Kept LAST in the chain + // so the seat scan is only reached after the cheap tests cull most objects. + rts::getSeatIndexForPlayer( getControllingPlayer()->getPlayerIndex() ) >= 0 ) TheRadar->tryUnderAttackEvent( this ); } From 3bc73deeac6495850ef162697024d4d110f06d2e Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:06:17 +0300 Subject: [PATCH 10/42] splitscreen: click-path probe for finding #8 (instrumentation, not a fix) handoff3's stated cause for #8 is refuted - getWindowUnderCursor IS seat-aware and its doc comment names that exact hypothesis as the bug it was added to kill - and its stated verification cannot be performed, because the translator trace filters to msg type >= MSG_BEGIN_META_MESSAGES (177) while MSG_MOUSE_LEFT_CLICK is 163. splitscreen_input.log has never been able to see a click. Adds the two readings that discriminate the surviving hypotheses, both gated on GX_CLICKPROBE so a normal build pays nothing: [GXPICK] in W3DView::pickDrawable - the acting seat, whether getWindowUnderCursor returned a window at that pixel, its id, and whether an opaque window is what refused the pick. [GXCLICK] in SelectionXlat at the empty-list break - the message's seat tag, getCommandActingSeat(), isPoint, the pixel region, and how many drawables the region actually yielded. Reading them: window non-null and owned by the acting seat's own bar => the narrowed window theory; window non-null via getWindowUnderCursor's m_grabWindow/m_mouseCaptor early return (which sit BEFORE any seat filter, and are swapped per seat by winBeginSeatInput) => a stale per-seat grab, which is the best surviving explanation; both null => the ray-cast itself missed; isPoint FALSE => the click never entered the pick path and the finding is misfiled. No fix is attempted - handoff3's own instruction was to get a repro before committing to a shape, and that is still right. --- .../Source/W3DDevice/GameClient/W3DView.cpp | 18 ++++++++++++++++++ .../GameClient/MessageStream/SelectionXlat.cpp | 12 ++++++++++++ 2 files changed, 30 insertions(+) diff --git a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp index 67d94c4b855..c6ac635bb77 100644 --- a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp +++ b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp @@ -43,6 +43,7 @@ #include "Common/BuildAssistant.h" #include "Common/FramePacer.h" #include "Common/GameUtility.h" +#include "Common/SeatManager.h" // splitscreen: seatLog (finding #8 click probe) #include "Common/GlobalData.h" #include "Common/Module.h" #include "Common/Radar.h" @@ -2554,11 +2555,28 @@ Drawable *W3DView::pickDrawable( const ICoord2D *screen, Bool forceAttack, PickT if (TheWindowManager) window = TheWindowManager->getWindowUnderCursor(screen->x, screen->y); + // Splitscreen probe (finding #8): a point click collapses to this single ray-cast, and a + // null return kills the whole selection - while drag-select never comes through here at + // all. The existing splitscreen_input.log is structurally blind to clicks (it filters to + // >= MSG_BEGIN_META_MESSAGES = 177, and MSG_MOUSE_LEFT_CLICK is 163), so nothing recorded + // whether the window gate is what refuses. Env-gated so it costs nothing unless asked for. + const Bool probeClick = (getenv("GX_CLICKPROBE") != nullptr); + if (probeClick) + seatLog("[GXPICK] pick at (%d,%d) actingSeat=%d windowUnderCursor=%s id=%d", + screen->x, screen->y, getCommandActingSeat(), + window ? "YES" : "null", + window ? (Int)window->winGetWindowId() : -1); + while (window) { // check to see if it or any of its parents are opaque. If so, we can't select anything. if (!BitIsSet( window->winGetStatus(), WIN_STATUS_SEE_THRU )) + { + if (probeClick) + seatLog("[GXPICK] REFUSED by opaque window id=%d - pick returns null", + (Int)window->winGetWindowId()); return nullptr; + } window = window->winGetParent(); } diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp index 75a9e75b850..c86781224f3 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp @@ -31,6 +31,7 @@ #include "Common/ActionManager.h" #include "Common/GameAudio.h" #include "Common/GameEngine.h" +#include "Common/SeatManager.h" // splitscreen: seatLog (finding #8 click probe) #include "Common/MessageStream.h" #include "Common/MiscAudio.h" #include "Common/Player.h" @@ -613,6 +614,17 @@ GameMessageDisposition SelectionTranslator::translateGameMessage(const GameMessa pds.isPointSelection = isPoint; getCommandActingView()->iterateDrawablesInRegion(&selectionRegion, addDrawableToList, &pds); + // Splitscreen probe (finding #8): "click does nothing, drag works" for a pad seat. + // isPoint separates "MetaEvent never collapsed it to a point" from a real point + // click, and an empty list here is the exact moment the selection dies. Pair with + // [GXPICK] from W3DView::pickDrawable, which says whether a window refused the pick. + if (getenv("GX_CLICKPROBE") != nullptr) + seatLog("[GXCLICK] seat=%d actingSeat=%d isPoint=%d region=(%d,%d)-(%d,%d) willSelect=%d", + msg->getSeatIndex(), getCommandActingSeat(), (Int)isPoint, + selectionRegion.lo.x, selectionRegion.lo.y, + selectionRegion.hi.x, selectionRegion.hi.y, + (Int)drawablesThatWillSelect.size()); + if (drawablesThatWillSelect.empty()) { break; From 3cd83c6e62cd3bc188627e45db7b3ba39b5e9c25 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:09:20 +0300 Subject: [PATCH 11/42] splitscreen: end-of-match splash covered every viewport, and seats 1-7 never got one The handoff's fix shape for this would have shipped a no-op. It said to make ScriptActions::m_messageWindow a [MAX_SEATS] array and resolve getSeatIndexForPlayer 'for the player the script action concerns'. There is no such player at those call sites, and only entry [0] would ever be written: the MP victory/defeat SCRIPTS are appended to side 0 alone (GameLogic.cpp:1607) and their conditions resolve through m_localSlotNum, so doVictory/doDefeat/ doLocalDefeat fire at most once per match, for seat 0's player. Seats 1..7 never reach ScriptActions at all. TheScriptEngine->getCurrentPlayer() is live there but is side 0's player, so threading it in would have looked correct while mislabelling every splash. So this needs two halves, and does both. (a) Positioning. The splash pointer moves out of the file-scope static in ScriptActions and into SeatUIContext (Pattern B, as c2a26a4e8 did for the message feed), behind InGameUI::showOutcomeSplashForSeat(). A seat whose view is smaller than the display gets the layout scaled and centred into its own viewport, using the same root-takes-the-transform mapping as ControlBar::dockToRect. winCreateFromScript returns only the first root, so the WindowLayoutInfo out-param is used to reach them all. (b) The missing trigger. VictoryConditions::update is the one place that already detects defeat per player and victory per alliance, and c2a26a4e8 already wired it to getSeatIndexForPlayer. Seats > 0 now get LocalDefeat.wnd on elimination and Victorious/Defeat.wnd when the match resolves. The seat>0 path deliberately runs NOTHING else from doLocalDefeat - doDisableInput, closeWindows, startCloseWindowTimer, SetVictorious and markMPLocalDefeatWindowShown are all machine-global. Seat 3 losing must not freeze seats 0-2 or end the match, and leaving MPLocalDefeatWindowShown alone keeps seat 0's later victory as Victorious.wnd rather than ObserverQuit.wnd. Single-view is byte-identical: the transform is gated on splitscreen being enabled AND the seat's view being strictly smaller than the display, which is never true for seat 0; the new triggers are guarded > 0. --- .../GameEngine/Include/GameClient/InGameUI.h | 11 +++ .../Include/GameLogic/ScriptActions.h | 8 +- .../GameEngine/Source/GameClient/InGameUI.cpp | 96 +++++++++++++++++++ .../GameLogic/ScriptEngine/ScriptActions.cpp | 20 ++-- .../ScriptEngine/VictoryConditions.cpp | 35 +++++++ 5 files changed, 158 insertions(+), 12 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h index 9bdf86dce1e..af74ee45dfe 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h @@ -389,6 +389,12 @@ friend class Drawable; // for selection/deselection transactions // Used for messages that concern one particular player (e.g. a defeat notice) rather // than the local UI in general. virtual void messageForSeat( Int seat, AsciiString stringManagerLabel, ... ); + // Splitscreen: show an end-of-match splash (Victorious/Defeat/LocalDefeat) inside ONE seat's + // viewport. The .wnd files are authored against the whole display, so seat 0 keeps the + // authored placement untouched and only a seat with a sub-display viewport is scaled and + // translated into it - which makes single-view byte-identical. + virtual void showOutcomeSplashForSeat( Int seat, const AsciiString& wndFile ); + virtual void closeOutcomeSplashes(); ///< destroy every seat's splash (between matches) virtual void toggleMessages() { m_messagesOn = 1 - m_messagesOn; } ///< toggle messages on/off virtual Bool isMessagesOn() { return m_messagesOn; } ///< are the display messages on void freeMessageResources(); ///< free resources for the ui messages @@ -756,6 +762,11 @@ friend class Drawable; // for selection/deselection transactions // mouse-over feedback DrawableID m_mousedOverDrawableID; ///< drawable currently under this seat's cursor + // end-of-match splash (Victorious/Defeat/LocalDefeat) owned by this seat and drawn in + // its own viewport. Was one file-scope static in ScriptActions, so the popup covered + // every viewport at once and only one seat could ever have one. + GameWindow *m_outcomeSplash; + // text message feed (was a single flat InGameUI member; per-seat so a message // concerning one seat's player draws in that seat's own viewport, not always seat 0's) UIMessage m_uiMessages[ MAX_UI_MESSAGES ]; diff --git a/GeneralsMD/Code/GameEngine/Include/GameLogic/ScriptActions.h b/GeneralsMD/Code/GameEngine/Include/GameLogic/ScriptActions.h index 0e694b4c62f..8c3b1a65824 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameLogic/ScriptActions.h +++ b/GeneralsMD/Code/GameEngine/Include/GameLogic/ScriptActions.h @@ -81,8 +81,12 @@ class ScriptActions : public ScriptActionsInterface protected: - static GameWindow *m_messageWindow; - static void clearWindow() {m_messageWindow=nullptr;}; + // Splitscreen: the end-of-match splash used to be ONE static window here, shared by + // doVictory/doDefeat/doLocalDefeat - so it covered every viewport and only one seat could + // have one. It now lives per seat in InGameUI::SeatUIContext, reached through + // TheInGameUI->showOutcomeSplashForSeat() / closeOutcomeSplashes(). Deliberately NOT an + // array here: this is a GameLogic header and seat state has no business in it. + // (clearWindow() went with it - it had zero callers repo-wide.) Bool m_suppressNewWindows; AsciiString m_unnamedUnit; diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index 87bc5c50393..3129168c85e 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -1099,6 +1099,7 @@ InGameUI::SeatUIContext::SeatUIContext() m_preferSelection = FALSE; m_mousedOverDrawableID = INVALID_DRAWABLE_ID; + m_outcomeSplash = nullptr; for( i = 0; i < MAX_UI_MESSAGES; ++i ) { @@ -2229,6 +2230,9 @@ void InGameUI::reset() // drawn - relying on that alone let the extra bars survive into the main menu. ControlBarInstances::destroySeatInstances(); + // Splitscreen: take down every seat's end-of-match splash too. + closeOutcomeSplashes(); + // Splitscreen: clear every seat's drag flag on the way out of a match. Nothing else does, // so a seat that was mid-lasso when the match ended would carry m_isDragSelecting into the // next one and paint a frozen box from the old match's coordinates. @@ -2408,6 +2412,98 @@ void InGameUI::message( AsciiString stringManagerLabel, ... ) } } +//------------------------------------------------------------------------------------------------- +/** Splitscreen: show an end-of-match splash inside ONE seat's viewport. + * + * The Victorious/Defeat/LocalDefeat layouts are authored against the whole display, and used + * to be created into a single file-scope static in ScriptActions - so the popup blanketed + * every viewport and only one seat could own one at a time. + * + * Seat 0 keeps the authored placement untouched: the transform below only runs for a seat + * whose view is strictly smaller than the display, which in a single-view game is never true + * (seat 0's view IS the full-display tactical view). */ +//------------------------------------------------------------------------------------------------- +void InGameUI::showOutcomeSplashForSeat( Int seat, const AsciiString& wndFile ) +{ + if( seat < 0 || seat >= MAX_SEATS ) + return; + + // one splash per seat; a second outcome replaces the first + if( m_seatContexts[ seat ].m_outcomeSplash ) + { + TheWindowManager->winDestroy( m_seatContexts[ seat ].m_outcomeSplash ); + m_seatContexts[ seat ].m_outcomeSplash = nullptr; + } + + // winCreateFromScript returns only the FIRST top-level window; info.windows holds every + // root, which is what has to be transformed. (The pre-existing single-root ownership - and + // therefore the pre-existing multi-root leak - is deliberately preserved here.) + WindowLayoutInfo info; + GameWindow *root = TheWindowManager->winCreateFromScript( wndFile, &info ); + m_seatContexts[ seat ].m_outcomeSplash = root; + + if( TheSeatManager == nullptr || !TheSeatManager->isSplitscreenEnabled() ) + return; + + LocalSeat *localSeat = TheSeatManager->getSeat( seat ); + if( localSeat == nullptr || localSeat->m_view == nullptr ) + return; + + const Int viewW = localSeat->m_view->getWidth(); + const Int viewH = localSeat->m_view->getHeight(); + const Int dispW = TheDisplay ? TheDisplay->getWidth() : viewW; + const Int dispH = TheDisplay ? TheDisplay->getHeight() : viewH; + + // full-display view => authored placement is already right, leave it exactly alone + if( viewW <= 0 || dispW <= 0 || (viewW >= dispW && viewH >= dispH) ) + return; + + Int viewX = 0, viewY = 0; + localSeat->m_view->getOrigin( &viewX, &viewY ); + + // same mapping ControlBar::dockToRect uses: roots take the scale and the translation, + // children stay parent-relative and are left untouched. + const Real targetScale = (Real)viewW / (Real)dispW; + + for( std::list::iterator it = info.windows.begin(); it != info.windows.end(); ++it ) + { + GameWindow *win = *it; + if( win == nullptr ) + continue; + + Int w = 0, h = 0, x = 0, y = 0; + win->winGetSize( &w, &h ); + win->winGetPosition( &x, &y ); + + const Int newW = (Int)(w * targetScale); + const Int newH = (Int)(h * targetScale); + + // these are centred splashes, not a docked bar - centre the scaled tree in the viewport + const Int newX = viewX + (viewW - newW) / 2; + const Int newY = viewY + (viewH - newH) / 2; + + win->winSetSize( newW, newH ); + win->winSetPosition( newX, newY ); + } +} + +//------------------------------------------------------------------------------------------------- +/** Splitscreen: destroy every seat's end-of-match splash. Called between matches. */ +//------------------------------------------------------------------------------------------------- +void InGameUI::closeOutcomeSplashes() +{ + for( Int seat = 0; seat < MAX_SEATS; ++seat ) + { + if( m_seatContexts[ seat ].m_outcomeSplash ) + { + TheWindowManager->winDestroy( m_seatContexts[ seat ].m_outcomeSplash ); + // null immediately: reset() and ScriptActions::closeWindows can both run on the way + // out of a match, and a stale pointer here is a double-destroy. + m_seatContexts[ seat ].m_outcomeSplash = nullptr; + } + } +} + //------------------------------------------------------------------------------------------------- /** Same as message(), but for a message that concerns one specific seat (e.g. a per-player * defeat notice) rather than the local UI in general - queues onto that seat's own message diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/ScriptActions.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/ScriptActions.cpp index 023533d1b4a..6912d55f297 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/ScriptActions.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/ScriptActions.cpp @@ -119,7 +119,6 @@ static void updateTeamAndPlayerStuff( Object *obj, void *userData ) // GLOBALS //////////////////////////////////////////////////////////////////////////////////////// ScriptActionsInterface *TheScriptActions = nullptr; -GameWindow *ScriptActions::m_messageWindow = nullptr; //------------------------------------------------------------------------------------------------- //------------------------------------------------------------------------------------------------- @@ -173,10 +172,11 @@ void ScriptActions::closeWindows( Bool suppressNewWindows ) { m_suppressNewWindows = suppressNewWindows; - if (m_messageWindow) { - TheWindowManager->winDestroy(m_messageWindow); - m_messageWindow = nullptr; - } + // Splitscreen: the splash is per seat now (InGameUI::SeatUIContext), not one static here. + // Null-checked because this is reachable from ScriptActions::reset during teardown, where + // TheInGameUI may already be gone. + if (TheInGameUI) + TheInGameUI->closeOutcomeSplashes(); } //------------------------------------------------------------------------------------------------- @@ -214,10 +214,10 @@ void ScriptActions::doVictory() const Player *localPlayer = ThePlayerList->getLocalPlayer(); Bool showObserverWindow = localPlayer->isPlayerObserver() || TheScriptEngine->hasShownMPLocalDefeatWindow(); if(showObserverWindow) - m_messageWindow = TheWindowManager->winCreateFromScript("Menus/ObserverQuit.wnd"); + TheInGameUI->showOutcomeSplashForSeat( 0, "Menus/ObserverQuit.wnd" ); else { - m_messageWindow = TheWindowManager->winCreateFromScript("Menus/Victorious.wnd"); + TheInGameUI->showOutcomeSplashForSeat( 0, "Menus/Victorious.wnd" ); } } if(TheCampaignManager) @@ -238,10 +238,10 @@ void ScriptActions::doDefeat() const Player *localPlayer = ThePlayerList->getLocalPlayer(); Bool showObserverWindow = localPlayer->isPlayerObserver() || TheScriptEngine->hasShownMPLocalDefeatWindow(); if(showObserverWindow) - m_messageWindow = TheWindowManager->winCreateFromScript("Menus/ObserverQuit.wnd"); + TheInGameUI->showOutcomeSplashForSeat( 0, "Menus/ObserverQuit.wnd" ); else { - m_messageWindow = TheWindowManager->winCreateFromScript("Menus/Defeat.wnd"); + TheInGameUI->showOutcomeSplashForSeat( 0, "Menus/Defeat.wnd" ); } } if(TheCampaignManager) @@ -260,7 +260,7 @@ void ScriptActions::doLocalDefeat() if (!m_suppressNewWindows) { if(!TheVictoryConditions->amIObserver()) - m_messageWindow = TheWindowManager->winCreateFromScript("Menus/LocalDefeat.wnd"); + TheInGameUI->showOutcomeSplashForSeat( 0, "Menus/LocalDefeat.wnd" ); } if(TheCampaignManager) TheCampaignManager->SetVictorious(FALSE); diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/VictoryConditions.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/VictoryConditions.cpp index cabc9663797..4084b758443 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/VictoryConditions.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/ScriptEngine/VictoryConditions.cpp @@ -191,7 +191,24 @@ void VictoryConditions::update() Player* victoriousPlayer = findFirstUndefeatedPlayer(); if (victoriousPlayer) + { markAllianceVictorious(victoriousPlayer); + + // Splitscreen: give every seat BUT seat 0 its own end-of-match splash, in its + // own viewport. Seat 0 keeps the existing script-driven path untouched, so its + // behaviour (and single-view) is unchanged. Runs once per match, because + // m_singleAllianceRemaining latches above. + for (Int j = 0; j < MAX_PLAYER_COUNT; ++j) + { + if (m_players[j] == nullptr) + continue; + + const Int s = rts::getSeatIndexForPlayer( m_players[j]->getPlayerIndex() ); + if (s > 0) + TheInGameUI->showOutcomeSplashForSeat( s, + m_isVictorious[j] ? "Menus/Victorious.wnd" : "Menus/Defeat.wnd" ); + } + } } } @@ -213,6 +230,24 @@ void VictoryConditions::update() const Int concernedSeat = rts::getSeatIndexForPlayer( p->getPlayerIndex() ); TheInGameUI->messageForSeat( (concernedSeat >= 0) ? concernedSeat : 0, "GUI:PlayerHasBeenDefeated", p->getPlayerDisplayName().str() ); + + // Splitscreen: seats 1..7 never get a defeat splash at all. The MP victory and + // defeat SCRIPTS are attached to side 0 only (GameLogic.cpp:1607) and their + // conditions resolve through m_localSlotNum, so doDefeat/doLocalDefeat fire at + // most once per match and only for seat 0's player - seats 1..7 never reach + // ScriptActions. This is the one place that already detects defeat per player. + // + // LocalDefeat, not Defeat: one player is out while the match continues, which is + // exactly what MULTIPLAYER_PLAYER_DEFEAT -> doLocalDefeat means at retail. + // + // Deliberately NOTHING else from the doLocalDefeat path runs here - not + // doDisableInput, closeWindows, startCloseWindowTimer, SetVictorious, nor + // markMPLocalDefeatWindowShown. Every one of those is machine-global: seat 3 + // losing must not freeze seats 0-2 or end the game, and leaving the + // MPLocalDefeatWindowShown flag alone keeps seat 0's later victory showing + // Victorious.wnd instead of degrading to ObserverQuit.wnd. + if( concernedSeat > 0 ) + TheInGameUI->showOutcomeSplashForSeat( concernedSeat, "Menus/LocalDefeat.wnd" ); // People are boneheads. Also play a sound static AudioEventRTS leftGameSound("GUIMessageReceived"); TheAudio->addAudioEvent(&leftGameSound); From 47e0b8666c6c84124ac7d420a83f5ceb447741fa Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:11:22 +0300 Subject: [PATCH 12/42] docs: handoff4 - nine findings landed, test recipe, and why #1/#11 were held Adds the per-finding test recipe mirelle asked for, leading with the two falsifiable predictions: #10's cursor should be stuck with exactly ONE unit selected and fine with two or more, and #7's pad lasso should DISAPPEAR rather than freeze when seat 0 pre-empts the drag. If either fails the diagnosis is wrong and should be re-opened rather than patched around. Records why #1, #11 and #9's arm/consume pair were deliberately not attempted: each is large, none is runtime-verifiable from a Mac, and each fails in a way a one-match smoke test misses - #1 crashes only on the SECOND match via the omitted forgetBarLayout, on a path single-view also takes; #11 leaves a seat's popup permanently on screen if the update-func half is missed; #9's arm side alone leaves a pad seat unable to place anything. Full plans are in section 6. --- PatchNotes/splitscreen-bugfix-handoff4.md | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/PatchNotes/splitscreen-bugfix-handoff4.md b/PatchNotes/splitscreen-bugfix-handoff4.md index 0ed7e7f105a..d86afd3ab80 100644 --- a/PatchNotes/splitscreen-bugfix-handoff4.md +++ b/PatchNotes/splitscreen-bugfix-handoff4.md @@ -26,6 +26,17 @@ on a Windows host (Release win32 x86, VS 2022 BuildTools, MSVC 14.44): | #4 bar scheme | `ce879e9b5` | per-bar recorded scheme + defeated-seat observer skin | | #13 shell radar | `79adf2a5c` | stale-instance resolution, not the claimed root count | | #10 cursor shape | `45077fb27` | real cause was `isLocallyControlled`, not `TheMouse` | +| #6 under-attack | `f06510e91` | narrow: gate + 4 messages + radar glow + EVA | +| #8 probe | `3bc73deea` | instrumentation only, `GX_CLICKPROBE`; no fix attempted | +| #2/#3 splash | `3cd83c6e6` | reposition **and** the missing seat>0 trigger | + +**Still open and deliberately NOT attempted: #1 and #11, plus #9's arm/consume pair.** +All three are large, none can be runtime-verified from a Mac, and each has a failure mode a +one-match smoke test would miss — #1's `forgetBarLayout` omission crashes only on the SECOND +match (and `ResetDiplomacy` runs on every teardown, single-view included); #11's routing fix +without the update-func fix leaves a seat's popup permanently on screen; #9's arm side without +the consume side leaves a pad seat unable to place anything at all. Landing any of them blind +would have compromised testing of the nine that did land. Their full plans are in §6. Baseline exe SHA256 before any change: `1C25A9BE5518C472943E860558AE8C4FCCC943875CF6AED48362C2F37BD38362`. After the six: `BD35E6BE7A851DDE8ECABD0B223485E0B91CAF8C8A97F79A8B6ECD82338EF1ED`. @@ -341,6 +352,32 @@ stack could be obtained. Next step is either installing the Debugging Tools for catching the exception with a `.pdb` (one is produced next to the exe), or getting the branch author's working run-directory layout and comparing. +## 6c. How to test what landed (for whoever has a pad) + +Build: `-splitscreendev `. The two sharpest falsifiable predictions first — if either fails, +that diagnosis is wrong and should be re-opened, not patched around. + +* **#10 cursor.** With a pad seat, select **exactly one** of that seat's own units. Before the + fix the cursor was pinned to ARROW; with **two or more** selected it changed shape normally. + That asymmetry is the signature. If the seat's cursor is still stuck with 2+ selected, the + `isLocallyControlled` diagnosis is incomplete. +* **#7 lasso.** A pad seat's drag box should now paint. Then, mid-drag on the pad, press the + mouse on seat 0: the pad's box must **disappear**, not freeze. A frozen box means the + hand-back in `RAW_MOUSE_LEFT_BUTTON_DOWN` did not fire. +* **#5.** An AI observer seat's control bar should start **empty** — no command centre selected. +* **#4.** Two seats on different factions should show **different** bar artwork. Then let player + 1 be defeated: only player 1's bar should go blank/observer, not all of them. +* **#9.** Arm a building placement on seat 0, then select a unit with the pad. Seat 0's + placement must survive. +* **#2/#3.** On elimination a seat should get its own LocalDefeat splash **inside its own + viewport**, and the other seats must keep playing — no global input freeze. +* **#6.** Attack a seat>0 unit: that seat gets the under-attack text in ITS viewport and ITS + radar flashes. Known limitation, logged above: within 10s another seat's warning may be + suppressed map-wide. +* **#13.** Main menu -> Options -> change resolution. No radar/bar over the shell map. +* **#8.** Run with `GX_CLICKPROBE=1` and click (not drag) with the pad, then read + `splitscreen_input.log` for `[GXPICK]` / `[GXCLICK]` and follow the decision table in §3. + ## 7. Process notes * `getCommandActingSeat()` is at **global scope**, not in namespace `rts`. Writing From 4a0abf97478657aeb4746621eb9015cf025f5257 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:21:39 +0300 Subject: [PATCH 13/42] splitscreen: the communicator always opened in the main window Diplomacy.cpp had no seat concept at all - grep for 'seat' returned zero hits in the whole file. One layout, one window, one AnimateWindowManager and one set of per-slot widget pointers, so the popup opened at Diplomacy.wnd's authored full-display position whoever pressed the button, and a second seat opening it stomped the first seat's pointers. ControlBarCallback called ToggleDiplomacy(FALSE) with no seat, unlike the GBM_MOUSE_ENTERING/LEAVING handlers directly above it and the generals button in the same switch, both of which already resolve the instance via ControlBarInstances::fromWindow. All per-INSTANCE state is now indexed by seat. The NameKeyTypes are NOT - they are derived from layout-name strings and are identical for every instance; turning them into arrays would have been pure noise, which is exactly what a scripted rename over that block would have produced. The bodies that walk slots alias the arrays back to their original local names so they stay untouched. Positioning is not enough on its own, and this is the part the handoff missed: winSeatOwnsWindow's own comment keeps 'the quit menu, diplomacy, message boxes, the whole shell' with seat 0, so a seat>0 could have seen a correctly-placed popup and been unable to press a single button in it. The layout is therefore adopted by the seat's own ControlBar, which is the only mechanism that exists for this - the generals screen and the special-power shortcut bar are the precedents. That buys position, per-frame re-dock, paint clipping AND click ownership together. Added ControlBar::adoptPopupLayout() as the public entry point (redockAfterRootsChanged is protected) and made it report the MAX_BAR_LAYOUT_WINDOWS overflow instead of silently half-docking, since a silent drop looks identical to the fix doing nothing. ResetDiplomacy calls forgetBarLayout BEFORE destroyWindows for every seat. That is mandatory, not hygiene: it runs on every match teardown including single-view, and without it dockToRect writes through freed GameWindows every frame - a crash that surfaces later inside winSetFont with an unrelated stack and only reproduces on the SECOND match. The five global winGetWindowFromId lookups are now winFindChildById scoped to the seat's own tree (bug class 1). Default arguments keep every existing caller compiling unchanged; seat < 0 means seat 0 for show/toggle and every seat for hide/reset/populate, so the GameLogic-side callers stay seat-free. --- .../Include/GameClient/ControlBar.h | 15 + .../GameEngine/Include/GameClient/Diplomacy.h | 4 +- .../Include/GameClient/GUICallbacks.h | 6 +- .../GameClient/GUI/ControlBar/ControlBar.cpp | 31 ++ .../GUI/GUICallbacks/ControlBarCallback.cpp | 7 +- .../GameClient/GUI/GUICallbacks/Diplomacy.cpp | 305 ++++++++++++++---- .../GameClient/MessageStream/CommandXlat.cpp | 3 +- 7 files changed, 299 insertions(+), 72 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBar.h b/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBar.h index 233ef64810d..2b79a1d44aa 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBar.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBar.h @@ -994,6 +994,21 @@ class ControlBar : public SubsystemInterface void forgetBarWindows( GameWindow *window ); void forgetBarLayout( WindowLayout *layout ); + /** Splitscreen: adopt a whole non-ControlBar.wnd popup layout into this bar's viewport. + + This is THE mechanism for putting a popup in a seat's viewport - there is no + general-purpose helper. Registering with the bar buys four things at once: position, + per-frame re-dock, paint clipping, and click ownership (winSeatOwnsWindow resolves + through ControlBar::ownsLayoutWindow, which otherwise keeps popups with seat 0, so an + unadopted popup on seat>0 is visible but completely unclickable). + + Pairs with forgetBarLayout(), which MUST be called before the layout's windows are + destroyed or the next dock writes through freed memory. + + Returns FALSE if the layout did not fit (addBarLayoutWindows silently drops past + MAX_BAR_LAYOUT_WINDOWS, which looks exactly like "the fix did nothing"). */ + Bool adoptPopupLayout( WindowLayout *layout ); + /** Resolve and set up this instance's windows. Split out of init() so a per-viewport bar can do it without re-loading the command buttons, command sets and scheme INI - that data describes the game, not the bar, and one copy is shared by every instance. */ diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/Diplomacy.h b/GeneralsMD/Code/GameEngine/Include/GameClient/Diplomacy.h index 50899f9fbf1..8006ad94536 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/Diplomacy.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/Diplomacy.h @@ -28,7 +28,9 @@ #pragma once -void PopulateInGameDiplomacyPopup(); +// Splitscreen: seat < 0 repopulates every seat that currently has a live popup, which keeps +// the GameLogic-side caller in VictoryConditions seat-free. +void PopulateInGameDiplomacyPopup( Int seat = -1 ); void UpdateDiplomacyBriefingText(AsciiString newText, Bool clear); typedef std::list BriefingList; diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/GUICallbacks.h b/GeneralsMD/Code/GameEngine/Include/GameClient/GUICallbacks.h index 4db166d7c7d..e6dc3326dd9 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/GUICallbacks.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/GUICallbacks.h @@ -347,8 +347,10 @@ Bool IsInGameChatActive(); // Diplomacy Controls -------------------------------------------------------------------------------- WindowMsgHandledType DiplomacySystem( GameWindow *window, UnsignedInt msg, WindowMsgData mData1, WindowMsgData mData2 ); WindowMsgHandledType DiplomacyInput( GameWindow *window, UnsignedInt msg, WindowMsgData mData1, WindowMsgData mData2 ); -void ToggleDiplomacy( Bool immediate = TRUE ); -void HideDiplomacy( Bool immediate = TRUE ); +// Splitscreen: seat < 0 on Toggle/Show means seat 0 (the classic single-view meaning); +// seat < 0 on Hide means every seat, since it is reached from GameLogic teardown. +void ToggleDiplomacy( Bool immediate = TRUE, Int seat = -1 ); +void HideDiplomacy( Bool immediate = TRUE, Int seat = -1 ); void ResetDiplomacy(); // Generals Exp Points -------------------------------------------------------------------------------- diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp index 177cd44f553..b59f326365c 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp @@ -1514,6 +1514,37 @@ void ControlBar::forgetBarWindows( GameWindow *window ) m_barRootWindow = nullptr; } +//------------------------------------------------------------------------------------------------- +/** Splitscreen: adopt a popup layout into this bar's viewport. See the header for why this is + * the only mechanism that works - in particular that without it a seat>0 popup is visible but + * unclickable, because winSeatOwnsWindow keeps unowned popups with seat 0. */ +//------------------------------------------------------------------------------------------------- +Bool ControlBar::adoptPopupLayout( WindowLayout *layout ) +{ + if( layout == nullptr ) + return FALSE; + + for( GameWindow *w = layout->getFirstWindow(); w; w = w->winGetNextInLayout() ) + { + GameWindow *one = w; + addBarLayoutWindows( &one, 1 ); + + // addBarLayoutWindows drops silently once full, and a half-registered popup docks + // half its tree - which reads as "the fix did nothing" rather than as an overflow. + if( m_barLayoutWindowCount >= MAX_BAR_LAYOUT_WINDOWS ) + { + DEBUG_CRASH(( "ControlBar::adoptPopupLayout - seat %d is out of bar layout slots (%d); " + "the popup will only be partly docked", m_seatIndex, MAX_BAR_LAYOUT_WINDOWS )); + redockAfterRootsChanged(); + return FALSE; + } + } + + redockAfterRootsChanged(); + return TRUE; +} + +//------------------------------------------------------------------------------------------------- void ControlBar::forgetBarLayout( WindowLayout *layout ) { if( layout == nullptr ) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/ControlBarCallback.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/ControlBarCallback.cpp index 48b3141bdff..0661e676e54 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/ControlBarCallback.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/ControlBarCallback.cpp @@ -410,7 +410,12 @@ WindowMsgHandledType ControlBarSystem( GameWindow *window, UnsignedInt msg, Int controlID = control->winGetWindowId(); if( controlID == buttonCommunicator ) { - ToggleDiplomacy(FALSE); + // Splitscreen: open it for the bar that was actually pressed. Without the seat + // this opened seat 0's popup at Diplomacy.wnd's authored full-display position + // no matter who pressed it - the GBM_MOUSE_ENTERING/LEAVING handlers above and + // the generals button below already resolve the instance this same way. + ControlBar *pressedBar = ControlBarInstances::fromWindow( control ); + ToggleDiplomacy(FALSE, pressedBar ? pressedBar->getSeatIndex() : 0); } else if( controlID == beaconPlacementButtonID && TheGameLogic->isInMultiplayerGame() && ThePlayerList->getLocalPlayer()->isPlayerActive()) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Diplomacy.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Diplomacy.cpp index 053f0e06faa..b00dbb45d4f 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Diplomacy.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Diplomacy.cpp @@ -46,6 +46,8 @@ #include "GameClient/GadgetTextEntry.h" #include "GameClient/GadgetStaticText.h" #include "GameClient/GadgetRadioButton.h" +#include "Common/SeatManager.h" // splitscreen: MAX_SEATS +#include "GameClient/ControlBar.h" // splitscreen: ControlBarInstances (popup goes in the seat bar) #include "GameClient/GameClient.h" #include "GameClient/GameText.h" #include "GameClient/GUICallbacks.h" @@ -66,35 +68,78 @@ static NameKeyType staticTextTeamID[MAX_SLOTS]; static NameKeyType staticTextStatusID[MAX_SLOTS]; static NameKeyType buttonMuteID[MAX_SLOTS]; static NameKeyType buttonUnMuteID[MAX_SLOTS]; +// The NameKeyType ids above are derived from layout-name strings and are IDENTICAL for every +// instance - one id set serves all seats, so they stay scalar. Only per-INSTANCE state below +// becomes per seat. static NameKeyType radioButtonInGameID = NAMEKEY_INVALID; static NameKeyType radioButtonBuddiesID = NAMEKEY_INVALID; -static GameWindow *radioButtonInGame = nullptr; -static GameWindow *radioButtonBuddies = nullptr; static NameKeyType winInGameID = NAMEKEY_INVALID; static NameKeyType winBuddiesID = NAMEKEY_INVALID; static NameKeyType winSoloID = NAMEKEY_INVALID; -static GameWindow *winInGame = nullptr; -static GameWindow *winBuddies = nullptr; -static GameWindow *winSolo = nullptr; -static GameWindow *staticTextPlayer[MAX_SLOTS] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}; -static GameWindow *staticTextSide[MAX_SLOTS] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}; -static GameWindow *staticTextTeam[MAX_SLOTS] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}; -static GameWindow *staticTextStatus[MAX_SLOTS] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}; -static GameWindow *buttonMute[MAX_SLOTS] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}; -static GameWindow *buttonUnMute[MAX_SLOTS] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}; -static Int slotNumInRow[MAX_SLOTS]; + +// Splitscreen: this whole file had no seat concept at all - one layout, one window, one set of +// widget pointers - so the communicator always opened at Diplomacy.wnd's authored full-display +// position no matter which seat pressed the button, and a second seat opening it stomped the +// first seat's pointers. Everything per-instance is now indexed by seat. +// +// Functions below alias these into local names of the original spelling, so the bodies that walk +// slots are untouched: fewer edited lines is the point, a scripted rename in this file compiled +// while being wrong once already. +static GameWindow *s_radioButtonInGame[MAX_SEATS] = {nullptr}; +static GameWindow *s_radioButtonBuddies[MAX_SEATS] = {nullptr}; +static GameWindow *s_winInGame[MAX_SEATS] = {nullptr}; +static GameWindow *s_winBuddies[MAX_SEATS] = {nullptr}; +static GameWindow *s_winSolo[MAX_SEATS] = {nullptr}; +static GameWindow *s_staticTextPlayer[MAX_SEATS][MAX_SLOTS] = {{nullptr}}; +static GameWindow *s_staticTextSide[MAX_SEATS][MAX_SLOTS] = {{nullptr}}; +static GameWindow *s_staticTextTeam[MAX_SEATS][MAX_SLOTS] = {{nullptr}}; +static GameWindow *s_staticTextStatus[MAX_SEATS][MAX_SLOTS] = {{nullptr}}; +static GameWindow *s_buttonMute[MAX_SEATS][MAX_SLOTS] = {{nullptr}}; +static GameWindow *s_buttonUnMute[MAX_SEATS][MAX_SLOTS] = {{nullptr}}; +static Int s_slotNumInRow[MAX_SEATS][MAX_SLOTS]; //------------------------------------------------------------------------------------------------- -static WindowLayout *theLayout = nullptr; -static GameWindow *theWindow = nullptr; -static AnimateWindowManager *theAnimateWindowManager = nullptr; +static WindowLayout *s_theLayout[MAX_SEATS] = {nullptr}; +static GameWindow *s_theWindow[MAX_SEATS] = {nullptr}; +static AnimateWindowManager *s_theAnimateWindowManager[MAX_SEATS] = {nullptr}; + +/// Which seat a window/layout belongs to, or -1. Used by the callbacks, which are handed a +/// window rather than a seat. +static Int seatForDiplomacyLayout( const WindowLayout *layout ) +{ + for( Int s = 0; s < MAX_SEATS; ++s ) + if( layout != nullptr && s_theLayout[s] == layout ) + return s; + return -1; +} + +static Int seatForDiplomacyWindow( GameWindow *window ) +{ + for( GameWindow *w = window; w != nullptr; w = w->winGetParent() ) + for( Int s = 0; s < MAX_SEATS; ++s ) + if( s_theWindow[s] == w ) + return s; + return -1; +} WindowMsgHandledType BuddyControlSystem( GameWindow *window, UnsignedInt msg, WindowMsgData mData1, WindowMsgData mData2); void InitBuddyControls(Int type); void updateBuddyInfo(); -static void grabWindowPointers() +static void grabWindowPointers( Int seat ) { + if (seat < 0 || seat >= MAX_SEATS) + return; + + GameWindow *theWindow = s_theWindow[seat]; + GameWindow **staticTextPlayer = s_staticTextPlayer[seat]; + GameWindow **staticTextSide = s_staticTextSide[seat]; + GameWindow **staticTextTeam = s_staticTextTeam[seat]; + GameWindow **staticTextStatus = s_staticTextStatus[seat]; + GameWindow **buttonMute = s_buttonMute[seat]; + GameWindow **buttonUnMute = s_buttonUnMute[seat]; + Int *slotNumInRow = s_slotNumInRow[seat]; + for (Int i=0; iwinGetWindowFromId(theWindow, staticTextPlayerID[i]); - staticTextSide[i] = TheWindowManager->winGetWindowFromId(theWindow, staticTextSideID[i]); - staticTextTeam[i] = TheWindowManager->winGetWindowFromId(theWindow, staticTextTeamID[i]); - staticTextStatus[i] = TheWindowManager->winGetWindowFromId(theWindow, staticTextStatusID[i]); - buttonMute[i] = TheWindowManager->winGetWindowFromId(theWindow, buttonMuteID[i]); - buttonUnMute[i] = TheWindowManager->winGetWindowFromId(theWindow, buttonUnMuteID[i]); + // scoped to THIS seat's tree - winFindChildById is the form the rest of the branch + // standardised on, and with N identical layouts a global lookup returns an arbitrary + // seat's widget (handoff2 5.2 bug class 1). + staticTextPlayer[i] = TheWindowManager->winFindChildById(theWindow, staticTextPlayerID[i]); + staticTextSide[i] = TheWindowManager->winFindChildById(theWindow, staticTextSideID[i]); + staticTextTeam[i] = TheWindowManager->winFindChildById(theWindow, staticTextTeamID[i]); + staticTextStatus[i] = TheWindowManager->winFindChildById(theWindow, staticTextStatusID[i]); + buttonMute[i] = TheWindowManager->winFindChildById(theWindow, buttonMuteID[i]); + buttonUnMute[i] = TheWindowManager->winFindChildById(theWindow, buttonUnMuteID[i]); slotNumInRow[i] = -1; } } -static void releaseWindowPointers() +static void releaseWindowPointers( Int seat ) { + // only THIS seat's row - clearing the shared set would blank another seat's open popup + if (seat < 0 || seat >= MAX_SEATS) + return; + for (Int i=0; im_animateWindows) { Bool wasFinished = theAnimateWindowManager->isFinished(); theAnimateWindowManager->update(); - if (theAnimateWindowManager->isFinished() && !wasFinished && theAnimateWindowManager->isReversed()) + if (theAnimateWindowManager->isFinished() && !wasFinished && theAnimateWindowManager->isReversed() && theWindow) theWindow->winHide( TRUE ); } } @@ -164,7 +225,8 @@ BriefingList* GetBriefingTextList() //------------------------------------------------------------------------------------------------- void UpdateDiplomacyBriefingText(AsciiString newText, Bool clear) { - GameWindow *listboxSolo = TheWindowManager->winGetWindowFromId(theWindow, NAMEKEY("Diplomacy.wnd:ListboxSolo")); + // Solo briefing text is a singleplayer feature - seat 0 only, by construction. + GameWindow *listboxSolo = TheWindowManager->winFindChildById(s_theWindow[0], NAMEKEY("Diplomacy.wnd:ListboxSolo")); if (clear) { @@ -191,8 +253,21 @@ void UpdateDiplomacyBriefingText(AsciiString newText, Bool clear) // ------------------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------------------ -void ShowDiplomacy( Bool immediate ) +void ShowDiplomacy( Bool immediate, Int seat ) { + // seat < 0 on show means seat 0 - the classic single-view meaning + if (seat < 0 || seat >= MAX_SEATS) + seat = 0; + + WindowLayout *&theLayout = s_theLayout[seat]; + GameWindow *&theWindow = s_theWindow[seat]; + AnimateWindowManager *&theAnimateWindowManager = s_theAnimateWindowManager[seat]; + GameWindow *&radioButtonInGame = s_radioButtonInGame[seat]; + GameWindow *&radioButtonBuddies = s_radioButtonBuddies[seat]; + GameWindow *&winInGame = s_winInGame[seat]; + GameWindow *&winBuddies = s_winBuddies[seat]; + GameWindow *&winSolo = s_winSolo[seat]; + if (!TheInGameUI->getInputEnabled() || TheGameLogic->isIntroMoviePlaying() || TheGameLogic->isLoadingMap()) return; @@ -217,18 +292,19 @@ void ShowDiplomacy( Bool immediate ) theAnimateWindowManager = NEW AnimateWindowManager; radioButtonInGameID = TheNameKeyGenerator->nameToKey("Diplomacy.wnd:RadioButtonInGame"); radioButtonBuddiesID = TheNameKeyGenerator->nameToKey("Diplomacy.wnd:RadioButtonBuddies"); - radioButtonInGame = TheWindowManager->winGetWindowFromId(nullptr, radioButtonInGameID); - radioButtonBuddies = TheWindowManager->winGetWindowFromId(nullptr, radioButtonBuddiesID); + // scoped to this seat's own tree, not a global walk that returns an arbitrary instance + radioButtonInGame = TheWindowManager->winFindChildById(theWindow, radioButtonInGameID); + radioButtonBuddies = TheWindowManager->winFindChildById(theWindow, radioButtonBuddiesID); winInGameID = TheNameKeyGenerator->nameToKey("Diplomacy.wnd:InGameParent"); winBuddiesID = TheNameKeyGenerator->nameToKey("Diplomacy.wnd:BuddiesParent"); winSoloID = TheNameKeyGenerator->nameToKey("Diplomacy.wnd:SoloParent"); - winInGame = TheWindowManager->winGetWindowFromId(nullptr, winInGameID); - winBuddies = TheWindowManager->winGetWindowFromId(nullptr, winBuddiesID); - winSolo = TheWindowManager->winGetWindowFromId(nullptr, winSoloID); + winInGame = TheWindowManager->winFindChildById(theWindow, winInGameID); + winBuddies = TheWindowManager->winFindChildById(theWindow, winBuddiesID); + winSolo = TheWindowManager->winFindChildById(theWindow, winSoloID); if (!TheRecorder->isMultiplayer()) { - GameWindow *listboxSolo = TheWindowManager->winGetWindowFromId(theWindow, NAMEKEY("Diplomacy.wnd:ListboxSolo")); + GameWindow *listboxSolo = TheWindowManager->winFindChildById(theWindow, NAMEKEY("Diplomacy.wnd:ListboxSolo")); if (listboxSolo) { for (BriefingList::iterator it = theBriefingList.begin(); it != theBriefingList.end(); ++it) @@ -262,9 +338,30 @@ void ShowDiplomacy( Bool immediate ) if (!immediate && TheGlobalData->m_animateWindows) theAnimateWindowManager->registerGameWindow( theWindow, WIN_ANIMATION_SLIDE_TOP, TRUE, 200 ); + // Splitscreen: hand this popup to the seat's own ControlBar. There is no general-purpose + // "put a layout in a seat's viewport" helper - registering with the bar IS the mechanism, + // and it is what the generals screen and the special-power shortcut bar already use. It + // buys four things at once: position, per-frame re-dock, paint clipping, AND click + // ownership - winSeatOwnsWindow resolves through ControlBar::ownsLayoutWindow, and without + // this a seat>0 could see the popup but not press a single button in it, because that + // function otherwise keeps diplomacy with seat 0 by design. + if (seat > 0) + { + ControlBar *bar = ControlBarInstances::get( seat ); + if (bar != nullptr) + { + bar->adoptPopupLayout( theLayout ); + + // keep the slide-in inside this seat's viewport instead of sweeping across others' + const IRegion2D &d = bar->getBarDockRect(); + if (d.hi.x > d.lo.x) + theAnimateWindowManager->setAnimationBounds( d.hi.x - d.lo.x, d.hi.y - d.lo.y ); + } + } + TheInGameUI->registerWindowLayout(theLayout); - grabWindowPointers(); - PopulateInGameDiplomacyPopup(); + grabWindowPointers(seat); + PopulateInGameDiplomacyPopup(seat); if(TheGameSpyInfo && TheGameSpyInfo->getLocalProfileID() != 0) { @@ -281,25 +378,62 @@ void ShowDiplomacy( Bool immediate ) // ------------------------------------------------------------------------------------------------ void ResetDiplomacy() { - if(theLayout) + // no-arg by design: this is reached from GameLogic (GameLogicDispatch closeWindows) and + // always means "every seat". + for (Int seat = 0; seat < MAX_SEATS; ++seat) { - TheInGameUI->unregisterWindowLayout(theLayout); - theLayout->destroyWindows(); - deleteInstance(theLayout); - InitBuddyControls(-1); - theLayout = nullptr; + if(s_theLayout[seat]) + { + // MANDATORY before destroyWindows(): a bar-registered layout torn down without this + // leaves ControlBar::dockToRect writing through freed GameWindows every frame, and + // the crash surfaces later inside winSetFont with an unrelated call stack. Same + // defect ControlBar.cpp documents for the superweapon strip. ResetDiplomacy runs on + // EVERY match teardown, so without this the second match crashes. + ControlBar *bar = ControlBarInstances::get( seat ); + if (bar != nullptr) + bar->forgetBarLayout( s_theLayout[seat] ); + + TheInGameUI->unregisterWindowLayout(s_theLayout[seat]); + s_theLayout[seat]->destroyWindows(); + deleteInstance(s_theLayout[seat]); + if (seat == 0) + InitBuddyControls(-1); + s_theLayout[seat] = nullptr; + } + s_theWindow[seat] = nullptr; + s_radioButtonInGame[seat] = nullptr; + s_radioButtonBuddies[seat] = nullptr; + s_winInGame[seat] = nullptr; + s_winBuddies[seat] = nullptr; + s_winSolo[seat] = nullptr; + releaseWindowPointers(seat); + + delete s_theAnimateWindowManager[seat]; + s_theAnimateWindowManager[seat] = nullptr; } - theWindow = nullptr; - - delete theAnimateWindowManager; - theAnimateWindowManager = nullptr; } // ------------------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------------------ -void HideDiplomacy( Bool immediate ) +void HideDiplomacy( Bool immediate, Int seat ) { - releaseWindowPointers(); + // seat < 0 on hide means EVERY seat - it is called from GameLogic teardown + if (seat < 0) + { + for (Int s = 0; s < MAX_SEATS; ++s) + if (s_theWindow[s]) + HideDiplomacy( immediate, s ); + return; + } + + if (seat >= MAX_SEATS) + return; + + releaseWindowPointers(seat); + + GameWindow *theWindow = s_theWindow[seat]; + AnimateWindowManager *theAnimateWindowManager = s_theAnimateWindowManager[seat]; + if (theWindow) { if (immediate || !TheGlobalData->m_animateWindows) @@ -309,7 +443,7 @@ void HideDiplomacy( Bool immediate ) } else { - if (theAnimateWindowManager->isFinished()) + if (theAnimateWindowManager && theAnimateWindowManager->isFinished()) theAnimateWindowManager->reverseAnimateWindow(); } } @@ -317,22 +451,28 @@ void HideDiplomacy( Bool immediate ) // ------------------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------------------ -void ToggleDiplomacy( Bool immediate ) +void ToggleDiplomacy( Bool immediate, Int seat ) { + // seat < 0 on toggle means seat 0 - the classic single-view meaning + if (seat < 0 || seat >= MAX_SEATS) + seat = 0; + // If we bring this up, let's hide the quit menu HideQuitMenu(); + GameWindow *theWindow = s_theWindow[seat]; + if (theWindow) { Bool show = theWindow->winIsHidden(); if (show) - ShowDiplomacy( immediate ); + ShowDiplomacy( immediate, seat ); else - HideDiplomacy( immediate ); + HideDiplomacy( immediate, seat ); } else { - ShowDiplomacy( immediate ); + ShowDiplomacy( immediate, seat ); } } @@ -409,20 +549,31 @@ WindowMsgHandledType DiplomacySystem( GameWindow *window, UnsignedInt msg, { GameWindow *control = (GameWindow *)mData1; NameKeyType controlID = (NameKeyType)control->winGetWindowId(); + + // Splitscreen: the callback is handed a window, not a seat - resolve which seat's + // popup this control belongs to by walking up to a known root. Falls back to 0. + Int seat = seatForDiplomacyWindow( control ); + if (seat < 0) + seat = 0; + + GameWindow *winInGame = s_winInGame[seat]; + GameWindow *winBuddies = s_winBuddies[seat]; + Int *slotNumInRow = s_slotNumInRow[seat]; + static NameKeyType buttonHideID = NAMEKEY( "Diplomacy.wnd:ButtonHide" ); if (controlID == buttonHideID) { - HideDiplomacy( FALSE ); + HideDiplomacy( FALSE, seat ); } else if( controlID == radioButtonInGameID) { - winInGame->winHide(FALSE); - winBuddies->winHide(TRUE); + if (winInGame) winInGame->winHide(FALSE); + if (winBuddies) winBuddies->winHide(TRUE); } else if( controlID == radioButtonBuddiesID) { - winInGame->winHide(TRUE); - winBuddies->winHide(FALSE); + if (winInGame) winInGame->winHide(TRUE); + if (winBuddies) winBuddies->winHide(FALSE); } for (Int i=0; i= 0) { TheGameInfo->getSlot(slotNumInRow[i])->mute(TRUE); - PopulateInGameDiplomacyPopup(); + PopulateInGameDiplomacyPopup(seat); break; } if (controlID == buttonUnMuteID[i] && slotNumInRow[i] >= 0) { TheGameInfo->getSlot(slotNumInRow[i])->mute(FALSE); - PopulateInGameDiplomacyPopup(); + PopulateInGameDiplomacyPopup(seat); break; } } @@ -454,11 +605,31 @@ WindowMsgHandledType DiplomacySystem( GameWindow *window, UnsignedInt msg, } -void PopulateInGameDiplomacyPopup() +void PopulateInGameDiplomacyPopup( Int seat ) { if (!TheGameInfo) return; + // seat < 0 => every seat with a live popup. Keeps the GameLogic-side caller seat-free. + if (seat < 0) + { + for (Int s = 0; s < MAX_SEATS; ++s) + if (s_theWindow[s]) + PopulateInGameDiplomacyPopup( s ); + return; + } + + if (seat >= MAX_SEATS) + return; + + GameWindow **staticTextPlayer = s_staticTextPlayer[seat]; + GameWindow **staticTextSide = s_staticTextSide[seat]; + GameWindow **staticTextTeam = s_staticTextTeam[seat]; + GameWindow **staticTextStatus = s_staticTextStatus[seat]; + GameWindow **buttonMute = s_buttonMute[seat]; + GameWindow **buttonUnMute = s_buttonUnMute[seat]; + Int *slotNumInRow = s_slotNumInRow[seat]; + Int rowNum = 0; for (Int slotNum=0; slotNumisInGame() && !TheGameLogic->isInShellGame()) { - ToggleDiplomacy( FALSE ); + // Splitscreen: the hotkey belongs to whichever seat pressed it + ToggleDiplomacy( FALSE, getCommandActingSeat() ); } else if( TheShell && TheShell->isShellActive() && TheGameSpyBuddyMessageQueue) GameSpyToggleOverlay(GSOVERLAY_BUDDY); From 7c91c33238f979d6c6fdbb1b801ca6434b0905e4 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:25:25 +0300 Subject: [PATCH 14/42] splitscreen: the build tooltip was one popup, on seat 0's bar, at seat 0's anchor commandButtonTooltip called the GLOBAL TheControlBar->showBuildTooltipLayout, so hovering any seat's button raised seat 0's tooltip, anchored off seat 0's marker. It now resolves the firing bar via ControlBarInstances::fromWindow - the mechanism WP8 built for exactly this - which falls back to TheControlBar, so single view is the same object and the same call. The routing fix alone is not safe, and this is the part the handoff does not mention: ControlBarPopupDescriptionUpdateFunc is installed on EVERY instance's layout and run per instance, but drove the global TheControlBar. Dormant only because no seat>0 layout was ever shown - and made live by the routing change itself. Without the companion fix, seat N's popup is evaluated against seat 0's m_showBuildToolTipLayout and never hides, while seat 0's layout is deleted instead. ControlBar::update now passes 'this' through runUpdate's existing userData parameter. Four hover/delay statics (one file static, two function statics, one more for the offset) become per-bar members - same defect and same fix as m_lastMoneyShown, whose comment already records that function statics made one bar's value suppress another's. Six global window lookups scoped: three ControlBar.wnd ids through findBarWindowById, three ControlBarPopupDescription.wnd ids through a new findTooltipWindowById that searches only this bar's own tooltip roots. The first three only ever resolved via winGetWindowFromId's sibling walk, so with N bars they matched an arbitrary bar's copy and the == test dropped into the DEBUG_CRASH. The BackgroundMarker anchor had a second defect independent of the lookup: getBackgroundMarkerPos returns an AUTHORED coordinate captured once at init, mixed against a DOCKED winGetScreenPosition - so the anchor is wrong for any docked bar even on its own. Scaled by the bar's dock scale, the correction W3DControlBar already carries. Exactly 1 for an undocked bar. BEHAVIOURAL DELTA, deliberate, not byte-identical: the tooltip now prices against getCurrentlyViewedPlayer() rather than ThePlayerList->getLocalPlayer(). Identical unless observer mode is on, where it now prices against the observed player - which matches what line 570 of the same function already did. NOT DONE, deliberately - the SIZE half. Registering the tooltip layout into the bar's dockToRect pass is what would make it shrink with the viewport, but populateBuildTooltipLayout grows the description box at runtime with raw winSetSize/winSetPosition, and dockToRect re-applies authored geometry every frame - in single view too. Registering without first converting those to placeBarWindow/resizeBarWindow would collapse the popup back to its authored 102px height on the very next frame, for everyone. That pair has to land together; logged in handoff4. Also dropped from the finding: theAnimateWindowManager. The file-scope flag 'useAnimation' is never assigned anywhere, so the manager is permanently nullptr and there is no slide-in to bound - a fix there would ship nothing while still forcing a redeploy. --- .../Include/GameClient/ControlBar.h | 9 ++ .../GameClient/GUI/ControlBar/ControlBar.cpp | 13 ++- .../ControlBarPopupDescription.cpp | 103 ++++++++++++------ 3 files changed, 89 insertions(+), 36 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBar.h b/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBar.h index 2b79a1d44aa..77445af5f0c 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBar.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBar.h @@ -1198,6 +1198,15 @@ class ControlBar : public SubsystemInterface WindowLayout *m_buildToolTipLayout; ///< The window that will slide on/display tooltips Bool m_showBuildToolTipLayout; ///< every frame we test to see if we are going to continue showing this or not. + /// Splitscreen: tooltip hover/delay state, per bar. These were a file static and two + /// function statics, which made one bar's hover suppress another's - same reason and same + /// fix as m_lastMoneyShown/m_lastIncomeShown above. + GameWindow *m_tooltipPrevWindow; + Bool m_tooltipWaitInitialized; + UnsignedInt m_tooltipBeginWaitTime; + ICoord2D m_tooltipLastOffset; + /// Resolve an id strictly inside this bar's OWN tooltip layout roots. + GameWindow *findTooltipWindowById( NameKeyType id ) const; public: void showBuildTooltipLayout( GameWindow *cmdButton ); void hideBuildTooltipLayout(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp index b59f326365c..e5a8d6aabe4 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp @@ -129,7 +129,12 @@ static void commandButtonTooltip(GameWindow *window, WinInstanceData *instData, UnsignedInt mouse) { - TheControlBar->showBuildTooltipLayout(window); + // Splitscreen: the tooltip belongs to the bar whose button is being hovered, not to the + // global one - hovering any seat's button showed SEAT 0's tooltip, positioned off seat 0's + // marker. fromWindow falls back to TheControlBar, so single view is the same object. + ControlBar *bar = ControlBarInstances::fromWindow( window ); + if( bar ) + bar->showBuildTooltipLayout(window); } /// mark the UI as dirty so the context of everything is re-evaluated @@ -1002,6 +1007,10 @@ ControlBar::ControlBar() m_observerLookAtPlayer = nullptr; m_observedPlayer = nullptr; m_buildToolTipLayout = nullptr; + m_tooltipPrevWindow = nullptr; + m_tooltipWaitInitialized = FALSE; + m_tooltipBeginWaitTime = 0; + m_tooltipLastOffset.x = m_tooltipLastOffset.y = 0; m_showBuildToolTipLayout = FALSE; m_animateDownWin1Pos.x = m_animateDownWin1Pos.y = 0; @@ -2379,7 +2388,7 @@ void ControlBar::update() if( !m_buildToolTipLayout->isHidden()) { - m_buildToolTipLayout->runUpdate(); + m_buildToolTipLayout->runUpdate( this ); // splitscreen: tell the update func which bar owns it m_showBuildToolTipLayout = FALSE; } /* diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/ControlBarPopupDescription.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/ControlBarPopupDescription.cpp index 10bfcb18b4d..b0070ce7e35 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/ControlBarPopupDescription.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/ControlBarPopupDescription.cpp @@ -97,17 +97,25 @@ static WindowLayout *theLayout = nullptr; static GameWindow *theWindow = nullptr; static AnimateWindowManager *theAnimateWindowManager = nullptr; -static GameWindow *prevWindow = nullptr; static Bool useAnimation = FALSE; void ControlBarPopupDescriptionUpdateFunc( WindowLayout *layout, void *param ) { + // Splitscreen: this update func is installed on EVERY instance's layout and run per + // instance, but drove the global TheControlBar - dormant only while no seat>0 layout was + // ever shown, and made live by the routing fix itself. Without this, seat N's popup is + // evaluated against seat 0's m_showBuildToolTipLayout and never hides, while seat 0's + // layout gets deleted instead. param is the owning bar (ControlBar::update passes `this`). + ControlBar *bar = (ControlBar *)param; + if( bar == nullptr ) + bar = TheControlBar; + if(TheScriptEngine->isGameEnding()) - TheControlBar->hideBuildTooltipLayout(); + bar->hideBuildTooltipLayout(); - if(theAnimateWindowManager && !TheControlBar->getShowBuildTooltipLayout() && !theAnimateWindowManager->isReversed()) + if(theAnimateWindowManager && !bar->getShowBuildTooltipLayout() && !theAnimateWindowManager->isReversed()) theAnimateWindowManager->reverseAnimateWindow(); - else if(!TheControlBar->getShowBuildTooltipLayout() && (!TheGlobalData->m_animateWindows || !useAnimation)) - TheControlBar->deleteBuildTooltipLayout(); + else if(!bar->getShowBuildTooltipLayout() && (!TheGlobalData->m_animateWindows || !useAnimation)) + bar->deleteBuildTooltipLayout(); if ( useAnimation && theAnimateWindowManager && TheGlobalData->m_animateWindows) @@ -118,7 +126,7 @@ void ControlBarPopupDescriptionUpdateFunc( WindowLayout *layout, void *param ) { delete theAnimateWindowManager; theAnimateWindowManager = nullptr; - TheControlBar->deleteBuildTooltipLayout(); + bar->deleteBuildTooltipLayout(); } } @@ -133,14 +141,12 @@ void ControlBar::showBuildTooltipLayout( GameWindow *cmdButton ) } Bool passedWaitTime = FALSE; - static Bool isInitialized = FALSE; - static UnsignedInt beginWaitTime; - if(prevWindow == cmdButton) + if(m_tooltipPrevWindow == cmdButton) { m_showBuildToolTipLayout = TRUE; - if(!isInitialized && beginWaitTime + cmdButton->getTooltipDelay() < timeGetTime()) + if(!m_tooltipWaitInitialized && m_tooltipBeginWaitTime + cmdButton->getTooltipDelay() < timeGetTime()) { - //DEBUG_LOG(("%d beginwaittime, %d tooltipdelay, %dtimegettime", beginWaitTime, cmdButton->getTooltipDelay(), timeGetTime())); + //DEBUG_LOG(("%d beginwaittime, %d tooltipdelay, %dtimegettime", m_tooltipBeginWaitTime, cmdButton->getTooltipDelay(), timeGetTime())); passedWaitTime = TRUE; } @@ -161,7 +167,7 @@ void ControlBar::showBuildTooltipLayout( GameWindow *cmdButton ) // deleteInstance(m_buildToolTipLayout); // m_buildToolTipLayout = nullptr; m_buildToolTipLayout->hide(TRUE); - prevWindow = nullptr; + m_tooltipPrevWindow = nullptr; } return; } @@ -170,12 +176,12 @@ void ControlBar::showBuildTooltipLayout( GameWindow *cmdButton ) // will only get here the firsttime through the function through this window if(!passedWaitTime) { - prevWindow = cmdButton; - beginWaitTime = timeGetTime(); - isInitialized = FALSE; + m_tooltipPrevWindow = cmdButton; + m_tooltipBeginWaitTime = timeGetTime(); + m_tooltipWaitInitialized = FALSE; return; } - isInitialized = TRUE; + m_tooltipWaitInitialized = TRUE; if(!cmdButton) return; @@ -233,20 +239,40 @@ void ControlBar::showBuildTooltipLayout( GameWindow *cmdButton ) void ControlBar::repopulateBuildTooltipLayout() { - if(!prevWindow || !m_buildToolTipLayout) + if(!m_tooltipPrevWindow || !m_buildToolTipLayout) return; - if(!BitIsSet(prevWindow->winGetStyle(), GWS_PUSH_BUTTON)) + if(!BitIsSet(m_tooltipPrevWindow->winGetStyle(), GWS_PUSH_BUTTON)) return; - const CommandButton *commandButton = (const CommandButton *)GadgetButtonGetData(prevWindow); + const CommandButton *commandButton = (const CommandButton *)GadgetButtonGetData(m_tooltipPrevWindow); populateBuildTooltipLayout(commandButton); } +//------------------------------------------------------------------------------------------------- +/** Splitscreen: resolve an id strictly inside THIS bar's own tooltip layout. The layout is a + * separate top-level .wnd, so a global lookup finds an arbitrary bar's copy once more than one + * bar exists. Loops every root because a layout may have more than one. */ +//------------------------------------------------------------------------------------------------- +GameWindow *ControlBar::findTooltipWindowById( NameKeyType id ) const +{ + if( m_buildToolTipLayout == nullptr || TheWindowManager == nullptr ) + return nullptr; + + for( GameWindow *w = m_buildToolTipLayout->getFirstWindow(); w; w = w->winGetNextInLayout() ) + if( GameWindow *found = TheWindowManager->winFindChildById( w, id ) ) + return found; + + return nullptr; +} + +//------------------------------------------------------------------------------------------------- void ControlBar::populateBuildTooltipLayout( const CommandButton *commandButton, GameWindow *tooltipWin) { if(!m_buildToolTipLayout) return; - Player *player = ThePlayerList->getLocalPlayer(); + // Splitscreen: price against the player THIS bar shows, not the machine-wide local one - + // line 570 of this same function already uses the per-instance accessor. + Player *player = getCurrentlyViewedPlayer(); UnicodeString name, cost, descrip; UnicodeString requiresFormat = UnicodeString::TheEmptyString, requiresList; Bool firstRequirement = true; @@ -382,7 +408,7 @@ void ControlBar::populateBuildTooltipLayout( const CommandButton *commandButton, descrip.concat( L"\n\n" ); descrip.concat( TheGameText->fetch( "TOOLTIP:TooltipCannotPurchaseBecauseQueueFull" ) ); } - else if( !TheUpgradeCenter->canAffordUpgrade( ThePlayerList->getLocalPlayer(), upgradeTemplate, FALSE ) ) + else if( !TheUpgradeCenter->canAffordUpgrade( getCurrentlyViewedPlayer(), upgradeTemplate, FALSE ) ) { descrip.concat( L"\n\n" ); descrip.concat( TheGameText->fetch( "TOOLTIP:TooltipNotEnoughMoneyToBuild" ) ); @@ -557,12 +583,12 @@ void ControlBar::populateBuildTooltipLayout( const CommandButton *commandButton, else if(tooltipWin) { - if( tooltipWin == TheWindowManager->winGetWindowFromId(m_buildToolTipLayout->getFirstWindow(), TheNameKeyGenerator->nameToKey("ControlBar.wnd:MoneyDisplay"))) + if( tooltipWin == findBarWindowById( TheNameKeyGenerator->nameToKey("ControlBar.wnd:MoneyDisplay") )) { name = TheGameText->fetch("CONTROLBAR:Money"); descrip = TheGameText->fetch("CONTROLBAR:MoneyDescription"); } - else if(tooltipWin == TheWindowManager->winGetWindowFromId(m_buildToolTipLayout->getFirstWindow(), TheNameKeyGenerator->nameToKey("ControlBar.wnd:PowerWindow")) ) + else if(tooltipWin == findBarWindowById( TheNameKeyGenerator->nameToKey("ControlBar.wnd:PowerWindow") ) ) { name = TheGameText->fetch("CONTROLBAR:Power"); descrip = TheGameText->fetch("CONTROLBAR:PowerDescription"); @@ -579,7 +605,7 @@ void ControlBar::populateBuildTooltipLayout( const CommandButton *commandButton, descrip.format(descrip, 0, 0); } } - else if(tooltipWin == TheWindowManager->winGetWindowFromId(m_buildToolTipLayout->getFirstWindow(), TheNameKeyGenerator->nameToKey("ControlBar.wnd:GeneralsExp")) ) + else if(tooltipWin == findBarWindowById( TheNameKeyGenerator->nameToKey("ControlBar.wnd:GeneralsExp") ) ) { name = TheGameText->fetch("CONTROLBAR:GeneralsExp"); descrip = TheGameText->fetch("CONTROLBAR:GeneralsExpDescription"); @@ -591,13 +617,13 @@ void ControlBar::populateBuildTooltipLayout( const CommandButton *commandButton, } } - GameWindow *win = TheWindowManager->winGetWindowFromId(m_buildToolTipLayout->getFirstWindow(), TheNameKeyGenerator->nameToKey("ControlBarPopupDescription.wnd:StaticTextName")); + GameWindow *win = findTooltipWindowById( TheNameKeyGenerator->nameToKey("ControlBarPopupDescription.wnd:StaticTextName") ); if(win) { GadgetStaticTextSetText(win, name); } - win = TheWindowManager->winGetWindowFromId(m_buildToolTipLayout->getFirstWindow(), TheNameKeyGenerator->nameToKey("ControlBarPopupDescription.wnd:StaticTextCost")); + win = findTooltipWindowById( TheNameKeyGenerator->nameToKey("ControlBarPopupDescription.wnd:StaticTextCost") ); if(win) { if( costToBuild > 0 ) @@ -611,12 +637,11 @@ void ControlBar::populateBuildTooltipLayout( const CommandButton *commandButton, } } - win = TheWindowManager->winGetWindowFromId(m_buildToolTipLayout->getFirstWindow(), TheNameKeyGenerator->nameToKey("ControlBarPopupDescription.wnd:StaticTextDescription")); + win = findTooltipWindowById( TheNameKeyGenerator->nameToKey("ControlBarPopupDescription.wnd:StaticTextDescription") ); if(win) { static NameKeyType winNamekey = TheNameKeyGenerator->nameToKey( "ControlBar.wnd:BackgroundMarker" ); - static ICoord2D lastOffset = { 0, 0 }; ICoord2D size, newSize, pos; Int diffSize; @@ -650,8 +675,10 @@ void ControlBar::populateBuildTooltipLayout( const CommandButton *commandButton, // heightChange = controlBarPos.y - m_defaultControlBarPosition.y; - GameWindow *marker = TheWindowManager->winGetWindowFromId(nullptr,winNamekey); - static ICoord2D basePos; + // Splitscreen: resolve the marker inside THIS bar, not with a global walk that returns + // an arbitrary instance's copy. + GameWindow *marker = findBarWindowById(winNamekey); + ICoord2D basePos; // was a static; nothing carries between bars now if(!marker) { return; @@ -660,13 +687,21 @@ void ControlBar::populateBuildTooltipLayout( const CommandButton *commandButton, ICoord2D curPos, offset; marker->winGetScreenPosition(&curPos.x,&curPos.y); + // getBackgroundMarkerPos returns an AUTHORED coordinate captured once at init, while + // winGetScreenPosition is a DOCKED one - so the anchor is wrong for any docked bar even + // on its own. Scale the authored side by this bar's dock scale, the same correction + // W3DControlBar already carries. Scale is exactly 1 for an undocked bar. + const Real markerScale = getBarDockScale(); + basePos.x = (Int)(basePos.x * markerScale); + basePos.y = (Int)(basePos.y * markerScale); + offset.x = curPos.x - basePos.x; offset.y = curPos.y - basePos.y; - parent->winSetPosition(pos.x, (pos.y - diffSize) + (offset.y - lastOffset.y)); + parent->winSetPosition(pos.x, (pos.y - diffSize) + (offset.y - m_tooltipLastOffset.y)); - lastOffset.x = offset.x; - lastOffset.y = offset.y; + m_tooltipLastOffset.x = offset.x; + m_tooltipLastOffset.y = offset.y; win->winGetSize(&size.x, &size.y); win->winSetSize(size.x, size.y + diffSize); @@ -692,7 +727,7 @@ void ControlBar::hideBuildTooltipLayout() void ControlBar::deleteBuildTooltipLayout() { m_showBuildToolTipLayout = FALSE; - prevWindow= nullptr; + m_tooltipPrevWindow= nullptr; m_buildToolTipLayout->hide(TRUE); // if(!m_buildToolTipLayout) // return; From 432d551939a0148472b1adabb3d9ade80927b4b2 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:27:00 +0300 Subject: [PATCH 15/42] splitscreen: a pad seat armed a build through seat 0's context and camera Completes finding #9. The earlier commit landed only the CLEAR-only sites, because moving the arm side alone would have been a regression: arm and consume were both pinned to a literal seat 0 by the legacy accessor family, and that symmetry is the only reason a pad seat's build completed at all - wrongly, through seat 0's pending placement and seat 0's cursor. Passing m_seatIndex to the arm calls while PlaceEventTranslator still read seat 0 would have made getPendingPlaceType() return nullptr for that seat, so seat N could arm a build and then never place anything. So the pair moves together here: arm - the three ControlBarCommandProcessing sites (DOZER_CONSTRUCT and the two special-power variants) now pass m_seatIndex, which is already in scope and already used elsewhere in the same function. consume - PlaceEventTranslator, the only consumer, resolves the acting seat once from the message and routes every placement read and write through it: getPendingPlaceType, isPlacementAnchored, getPendingPlaceSourceObjectID, getPlacementAngle, getPlacementPoints and the four placeBuildAvailable clears. Every seat-taking overload already existed. Third defect in the same path, not in the handoff: all three screenToTerrain calls projected through TheTacticalView - seat 0's camera. A pad seat's pixels were being unprojected through the wrong view, so even once the placement is armed in the right context the building would land in the wrong world position. They now go through the acting seat's view, falling back to TheTacticalView. Single view is unchanged: every seat resolves to 0 and getCommandActingView() returns TheTacticalView. --- .../ControlBarCommandProcessing.cpp | 6 +-- .../MessageStream/PlaceEventTranslator.cpp | 52 ++++++++++++------- 2 files changed, 36 insertions(+), 22 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommandProcessing.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommandProcessing.cpp index d3e87dbf653..12555a66c2f 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommandProcessing.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommandProcessing.cpp @@ -265,7 +265,7 @@ CBCommandStatus ControlBar::processCommandUI( GameWindow *control, } // tell the UI that we want to build something so we get a building at the cursor - TheInGameUI->placeBuildAvailable( commandButton->getThingTemplate(), m_currentSelectedDrawable ); + TheInGameUI->placeBuildAvailable( commandButton->getThingTemplate(), m_currentSelectedDrawable, m_seatIndex ); // splitscreen: arm THIS seat break; @@ -310,7 +310,7 @@ CBCommandStatus ControlBar::processCommandUI( GameWindow *control, } // tell the UI that we want to build something so we get a building at the cursor - TheInGameUI->placeBuildAvailable( commandButton->getThingTemplate(), draw ); + TheInGameUI->placeBuildAvailable( commandButton->getThingTemplate(), draw, m_seatIndex ); // splitscreen: arm THIS seat ProductionUpdateInterface* pu = obj->getProductionUpdateInterface(); if( pu ) @@ -352,7 +352,7 @@ CBCommandStatus ControlBar::processCommandUI( GameWindow *control, } // tell the UI that we want to build something so we get a building at the cursor - TheInGameUI->placeBuildAvailable( commandButton->getThingTemplate(), m_currentSelectedDrawable ); + TheInGameUI->placeBuildAvailable( commandButton->getThingTemplate(), m_currentSelectedDrawable, m_seatIndex ); // splitscreen: arm THIS seat ProductionUpdateInterface* pu = obj->getProductionUpdateInterface(); if( pu ) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/PlaceEventTranslator.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/PlaceEventTranslator.cpp index 0d6999bbd98..7c21ad17414 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/PlaceEventTranslator.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/PlaceEventTranslator.cpp @@ -64,6 +64,20 @@ GameMessageDisposition PlaceEventTranslator::translateGameMessage(const GameMess { GameMessageDisposition disp = KEEP_MESSAGE; + // Splitscreen: this translator is the ONLY consumer of the pending placement, and it read + // seat 0 unconditionally. Arm and consume were therefore both pinned to 0, which is the only + // reason a pad seat's build completed at all - wrongly, through seat 0's context and cursor. + // Moving the arm side alone would have left this reading seat 0 and made a pad seat unable + // to place anything, so the pair moves together. Idiom copied from CommandXlat. + const Int placeSeat = (msg->getSeatIndex() >= 0 && msg->getSeatIndex() < MAX_SEATS) + ? msg->getSeatIndex() : 0; + + // ...and project through THAT seat's camera. Using TheTacticalView here would send seat N's + // pixels through seat 0's view and land the building in the wrong world position. + View *placeView = getCommandActingView(); + if (placeView == nullptr) + placeView = TheTacticalView; + switch(msg->getType()) { @@ -73,26 +87,26 @@ GameMessageDisposition PlaceEventTranslator::translateGameMessage(const GameMess case GameMessage::MSG_RAW_MOUSE_LEFT_BUTTON_DOWN: { // if we're in a building placement mode, do the place and send to all players - const ThingTemplate *build = TheInGameUI->getPendingPlaceType(); - if( build && TheInGameUI->isPlacementAnchored() == FALSE ) + const ThingTemplate *build = TheInGameUI->getPendingPlaceType( placeSeat ); + if( build && TheInGameUI->isPlacementAnchored( placeSeat ) == FALSE ) { ICoord2D mouse = msg->getArgument(0)->pixel; Coord3D world; // translate mouse position to world position - TheTacticalView->screenToTerrain( &mouse, &world ); + placeView->screenToTerrain( &mouse, &world ); // // placing things causes a dozer to go over and build it ... get the dozer in question // from the in game UI // - Object *builderObject = TheGameLogic->findObjectByID( TheInGameUI->getPendingPlaceSourceObjectID() ); + Object *builderObject = TheGameLogic->findObjectByID( TheInGameUI->getPendingPlaceSourceObjectID( placeSeat ) ); // if our source object is gone cancel this whole placement process if( builderObject == nullptr ) { - TheInGameUI->placeBuildAvailable( nullptr, nullptr ); + TheInGameUI->placeBuildAvailable( nullptr, nullptr, placeSeat ); break; } @@ -107,7 +121,7 @@ GameMessageDisposition PlaceEventTranslator::translateGameMessage(const GameMess // down in some legal locations // // get the type of thing we want to build - const ThingTemplate *whatToBuild = TheInGameUI->getPendingPlaceType(); + const ThingTemplate *whatToBuild = TheInGameUI->getPendingPlaceType( placeSeat ); // // if the spot at which they choose to place this thing is illegal we won't start @@ -116,7 +130,7 @@ GameMessageDisposition PlaceEventTranslator::translateGameMessage(const GameMess LegalBuildCode lbc; lbc = TheBuildAssistant->isLocationLegalToBuild( &world, whatToBuild, - TheInGameUI->getPlacementAngle(), + TheInGameUI->getPlacementAngle( placeSeat ), BuildAssistant::USE_QUICK_PATHFIND | BuildAssistant::TERRAIN_RESTRICTIONS | BuildAssistant::CLEAR_PATH | @@ -154,13 +168,13 @@ GameMessageDisposition PlaceEventTranslator::translateGameMessage(const GameMess case GameMessage::MSG_MOUSE_LEFT_CLICK: { // if we're in a building placement mode, do the place and send to all players - const ThingTemplate *build = TheInGameUI->getPendingPlaceType(); + const ThingTemplate *build = TheInGameUI->getPendingPlaceType( placeSeat ); // ... and also remove any radius cursor that is active. // (srj sez: not sure if this is always necessary... more of a failsafe to make it go away.) TheInGameUI->setRadiusCursorNone(); - if (build && TheInGameUI->isPlacementAnchored()) + if (build && TheInGameUI->isPlacementAnchored( placeSeat )) { GameMessage *placeMsg; // Player *player = ThePlayerList->getLocalPlayer(); @@ -170,15 +184,15 @@ GameMessageDisposition PlaceEventTranslator::translateGameMessage(const GameMess Bool isLineBuild = TheBuildAssistant->isLineBuildTemplate( build ); // get the angle of the drawable at the cursor to use as the initial angle - angle = TheInGameUI->getPlacementAngle(); + angle = TheInGameUI->getPlacementAngle( placeSeat ); // get start point from the anchor arrow used to place and select angles - TheInGameUI->getPlacementPoints( &anchorStart, &anchorEnd ); + TheInGameUI->getPlacementPoints( &anchorStart, &anchorEnd, placeSeat ); // translate the screen position of start to world target location - TheTacticalView->screenToTerrain( &anchorStart, &world ); + placeView->screenToTerrain( &anchorStart, &world ); - Object *builderObj = TheGameLogic->findObjectByID( TheInGameUI->getPendingPlaceSourceObjectID() ); + Object *builderObj = TheGameLogic->findObjectByID( TheInGameUI->getPendingPlaceSourceObjectID( placeSeat ) ); //Kris: September 27, 2002 //Make sure we have enough CASH to build it! It's possible that between the @@ -209,7 +223,7 @@ GameMessageDisposition PlaceEventTranslator::translateGameMessage(const GameMess break; } // get out of pending placement mode, this will also clear the arrow anchor status - TheInGameUI->placeBuildAvailable( nullptr, nullptr ); + TheInGameUI->placeBuildAvailable( nullptr, nullptr, placeSeat ); break; } @@ -251,7 +265,7 @@ GameMessageDisposition PlaceEventTranslator::translateGameMessage(const GameMess placeMsg->appendObjectIDArgument( builderObj->getID() ); //The source object responsible for firing the special. // get out of pending placement mode, this will also clear the arrow anchor status - TheInGameUI->placeBuildAvailable( nullptr, nullptr ); + TheInGameUI->placeBuildAvailable( nullptr, nullptr, placeSeat ); // used the input disp = DESTROY_MESSAGE; @@ -274,7 +288,7 @@ GameMessageDisposition PlaceEventTranslator::translateGameMessage(const GameMess { Coord3D worldEnd; - TheTacticalView->screenToTerrain( &anchorEnd, &worldEnd ); + placeView->screenToTerrain( &anchorEnd, &worldEnd ); placeMsg->appendLocationArgument( worldEnd ); } @@ -282,7 +296,7 @@ GameMessageDisposition PlaceEventTranslator::translateGameMessage(const GameMess pickAndPlayUnitVoiceResponse( TheInGameUI->getAllSelectedDrawables(), placeMsg->getType() ); // get out of pending placement mode, this will also clear the arrow anchor status - TheInGameUI->placeBuildAvailable( nullptr, nullptr ); + TheInGameUI->placeBuildAvailable( nullptr, nullptr, placeSeat ); } else @@ -321,7 +335,7 @@ GameMessageDisposition PlaceEventTranslator::translateGameMessage(const GameMess case GameMessage::MSG_RAW_MOUSE_POSITION: { // if a building placement is in progress update the destination position - if (TheInGameUI->isPlacementAnchored()) + if (TheInGameUI->isPlacementAnchored( placeSeat )) { const Int PLACEMENT_DRAG_THRESHOLD_DIST = 5; // in pixels away from anchor point ICoord2D mouse = msg->getArgument(0)->pixel; @@ -331,7 +345,7 @@ GameMessageDisposition PlaceEventTranslator::translateGameMessage(const GameMess // if we have moved far enough away from the start point // ICoord2D start; - TheInGameUI->getPlacementPoints( &start, nullptr ); + TheInGameUI->getPlacementPoints( &start, nullptr, placeSeat ); Int x, y; x = mouse.x - start.x; From 955adc673d59c943ee75aff304aace7024637bda Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:33:55 +0300 Subject: [PATCH 16/42] docs: handoff4 - all findings landed, and the init crash root-caused with a debugger Two separate environment problems, neither in any fix from this round and both reproducing on the untouched baseline commit. The init crash is INI::loadFileDirectory throwing INI_CANT_OPEN_FILE for Data\INI\Weather (GameEngine.cpp:488), caught by the bare catch(...) at :788. The file exists in retail INIZH.big and the three sibling loads succeed - the difference is the five mod archives in the test install, whose ! prefix sorts them first and shadows the directory listing. Excluding them produces no C++ throw at all. Same archives the #13 analysis flagged as able to override ControlBar.wnd, biting somewhere else first. Underneath that sits a second, still-open failure: a first-chance access violation in W3DDisplay::getDisplayModeCount during display enumeration. An AV is not a C++ throw so catch(...) never sees it and the process dies with no crash record and zero stderr - which is precisely why this presented as 'crashes at init with no information' until a debugger was attached. Also records that CNC_GENERALS_ZH_PATH does not exist on this branch at all (an unbounded grep returns zero hits; it is a GeneralsX-fork addition), so this build finds data via the working directory only. --- PatchNotes/splitscreen-bugfix-handoff4.md | 60 ++++++++++++++++++++--- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/PatchNotes/splitscreen-bugfix-handoff4.md b/PatchNotes/splitscreen-bugfix-handoff4.md index d86afd3ab80..8f6bf5fd1b1 100644 --- a/PatchNotes/splitscreen-bugfix-handoff4.md +++ b/PatchNotes/splitscreen-bugfix-handoff4.md @@ -30,13 +30,20 @@ on a Windows host (Release win32 x86, VS 2022 BuildTools, MSVC 14.44): | #8 probe | `3bc73deea` | instrumentation only, `GX_CLICKPROBE`; no fix attempted | | #2/#3 splash | `3cd83c6e6` | reposition **and** the missing seat>0 trigger | -**Still open and deliberately NOT attempted: #1 and #11, plus #9's arm/consume pair.** -All three are large, none can be runtime-verified from a Mac, and each has a failure mode a -one-match smoke test would miss — #1's `forgetBarLayout` omission crashes only on the SECOND -match (and `ResetDiplomacy` runs on every teardown, single-view included); #11's routing fix -without the update-func fix leaves a seat's popup permanently on screen; #9's arm side without -the consume side leaves a pad seat unable to place anything at all. Landing any of them blind -would have compromised testing of the nine that did land. Their full plans are in §6. +| #1 communicator | `4a0abf974` | per-seat, **adopted into the seat's bar** (position alone is not enough) | +| #11 tooltip | `7c91c3323` | routing + per-bar state + anchor; **size-scaling half held, see below** | +| #9 arm/consume | `432d55193` | completes #9; also fixes seat N projecting through seat 0's camera | + +**Every finding from handoff3 is now landed except #12**, which the user explicitly deferred in +handoff3 itself and which needs a probe run rather than code. + +**One piece deliberately held: #11's SIZE half.** Registering the tooltip layout into the bar's +`dockToRect` pass is what would make it shrink with the viewport — but +`populateBuildTooltipLayout` grows the description box at runtime with raw +`winSetSize`/`winSetPosition`, and `dockToRect` re-applies authored geometry every frame, in +single view too. Registering without first converting those to `placeBarWindow`/`resizeBarWindow` +would collapse the popup to its authored 102px height on the next frame **for everyone**. That +pair has to land together. Baseline exe SHA256 before any change: `1C25A9BE5518C472943E860558AE8C4FCCC943875CF6AED48362C2F37BD38362`. After the six: `BD35E6BE7A851DDE8ECABD0B223485E0B91CAF8C8A97F79A8B6ECD82338EF1ED`. @@ -343,6 +350,45 @@ existing run directory. Dropping the new exe beside the mismatched ones gives `0 0 bytes of stderr. A run directory for a fresh build must take those two DLLs from that build. (`C:\dev\GenSplit-run` and `C:\dev\GenSplit-base-run` are set up correctly this way.) +### ROOT-CAUSED with a debugger — two SEPARATE environment problems, no code defect + +Debugging Tools for Windows were installed and the exception caught first-chance. **Neither +problem is in any fix from this round; both reproduce on the untouched baseline.** + +**Problem 1 — mod `.big` files break INI loading. SOLVED.** +``` +generalszh!INI::loadFileDirectory+0x15a [Core/.../INI/INI.cpp @ 222] <- throw INI_CANT_OPEN_FILE +generalszh!GameEngine::init+0x30e [GameEngine.cpp @ 488] <- "Data\INI\Weather" +``` +`loadFileDirectory` throws when it reads **zero** files. Line 488 is `Data\INI\Weather`; the three +sibling loads on 485-487 (`Default\Water`, `Water`, `Default\Weather`) all succeed, and +`Data\INI\Weather.ini` **does** exist in retail `INIZH.big`. The difference is the mod archives in +the test install: `!HotkeysLeikezeIndicatorsZH.big`, `!HotkeysLeikezeZH.big`, +`340_ControlBarProZH.big`, `340_ControlBarPro1440ZH.big`, `340_ControlBarPro-Fix1440ZH.big`. +Running against the 20 retail `.big` files **with those five excluded produces no C++ throw at +all**. The `!` prefix sorts them first and they shadow the directory listing. +*This is also the caveat the #13 analysis raised about `340_ControlBarPro*` overriding +`ControlBar.wnd` — the same archives, biting somewhere else first.* + +**Problem 2 — display-mode enumeration faults. STILL OPEN.** +Once the INI throw is gone, a first-chance access violation surfaces underneath: +``` +generalszh!W3DDisplay::getDisplayModeCount+0xe [W3DDisplay.cpp @ 514] <- resolutions.Count() +generalszh!W3DDisplay::init+0x357 [W3DDisplay.cpp @ 870] +generalszh!GameClient::init+0x573 [GameClient.cpp @ 331] +``` +An AV is not a C++ throw, so `catch(...)` never sees it and the process dies silently with **no +crash record and 0 bytes of stderr** — which is exactly why this looked like "crashes at init with +no information" before the debugger. The DXVK `d3d8.dll` is byte-identical to the one the working +GeneralsX binary uses on the same box, and `dxvk.conf` was present, so it is not a stale DLL. +Unresolved: whether this branch's SDL3 display path is compatible with DXVK at all, or wants real +D3D8. **mirelle's own machine presumably clears this** — it is the first thing to ask her. + +**Do not chase `CNC_GENERALS_ZH_PATH` on this branch — it does not exist here.** An unbounded grep +over the whole tree returns zero hits; it is a GeneralsX-fork addition. This build finds its data +via the working directory only, so a run directory must either contain the `.big` archives or the +process must be launched with the install as its CWD. + ### What is still unknown Why a fresh build dies that early. Not yet ruled out: a data/asset expectation this box does not satisfy, or a build-configuration difference from however the branch author builds. Note the From c06e8ed4a6113a2784bdb7bacd6796677bbc000b Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:34:13 +0300 Subject: [PATCH 17/42] docs: handoff4 - correct the status header and the SHA-verification advice --- PatchNotes/splitscreen-bugfix-handoff4.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/PatchNotes/splitscreen-bugfix-handoff4.md b/PatchNotes/splitscreen-bugfix-handoff4.md index 8f6bf5fd1b1..817514ad3b6 100644 --- a/PatchNotes/splitscreen-bugfix-handoff4.md +++ b/PatchNotes/splitscreen-bugfix-handoff4.md @@ -15,8 +15,8 @@ correct diagnosis. ## 1. Where things stand -Branch `splitscreen-documents`. Six fixes landed this round, each compiled and relink-verified -on a Windows host (Release win32 x86, VS 2022 BuildTools, MSVC 14.44): +Branch `splitscreen-documents`. **Every open finding from handoff3 is now landed except #12**, +each compiled and verified on a Windows host (Release win32 x86, VS 2022 BuildTools, MSVC 14.44): | finding | commit | what actually landed | |---|---|---| @@ -29,7 +29,6 @@ on a Windows host (Release win32 x86, VS 2022 BuildTools, MSVC 14.44): | #6 under-attack | `f06510e91` | narrow: gate + 4 messages + radar glow + EVA | | #8 probe | `3bc73deea` | instrumentation only, `GX_CLICKPROBE`; no fix attempted | | #2/#3 splash | `3cd83c6e6` | reposition **and** the missing seat>0 trigger | - | #1 communicator | `4a0abf974` | per-seat, **adopted into the seat's bar** (position alone is not enough) | | #11 tooltip | `7c91c3323` | routing + per-bar state + anchor; **size-scaling half held, see below** | | #9 arm/consume | `432d55193` | completes #9; also fixes seat N projecting through seat 0's camera | @@ -45,12 +44,14 @@ single view too. Registering without first converting those to `placeBarWindow`/ would collapse the popup to its authored 102px height on the next frame **for everyone**. That pair has to land together. -Baseline exe SHA256 before any change: `1C25A9BE5518C472943E860558AE8C4FCCC943875CF6AED48362C2F37BD38362`. -After the six: `BD35E6BE7A851DDE8ECABD0B223485E0B91CAF8C8A97F79A8B6ECD82338EF1ED`. -**The exe size never changed** (9,159,168 bytes at every step) — gate on the SHA, never the size. +**How to prove a change actually compiled in.** The exe SHA is NOT sufficient: +`WinMain.cpp:986` bakes `__TIME__`/`__DATE__` into the binary, so the hash moves on any rebuild — +a docs-only commit changed it. Check for a **symbol you introduced**, in the static lib (Release +keeps names there, the linked exe does not): +`dumpbin /SYMBOLS build/win32/GeneralsMD/Code/GameEngine/Release/z_gameengine.lib | findstr ` -**Nothing in this round is runtime-verified.** Every fix is a static argument plus a clean -compile. Four findings remain open, below. +**Nothing here is runtime-verified.** Every fix is a static argument plus a clean compile and a +symbol check. §6c is the test recipe. ## 2. Build recipe that actually works From aa7cd24998f7d83ef2a406decd1d705871fcd1f8 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:51:24 +0300 Subject: [PATCH 18/42] docs: handoff4 - localise the second crash to the D3D8 stub being loaded The AV in W3DDisplay::getDisplayModeCount is fully explained: dx8wrapper does LoadLibrary("D3D8.DLL") and the process loads Windows' SysWOW64 stub rather than the DXVK copy sitting next to the exe, so zero render devices are enumerated and Get_Render_Device_Desc(0) returns a garbage reference. Records what has been ruled out by measurement so it is not re-derived: the DXVK dll is present and byte-identical to the one the working binary uses, its dependencies resolve, vulkan-1 is present, the GPU is capable, dxvk.conf makes no difference, mirroring the entire working run directory still reproduces it, and SDL3 does not harden the process-wide DLL search path (it scopes LOAD_LIBRARY_SEARCH_SYSTEM32 to combase.dll only). That leaves it as a difference in this binary's loader behaviour versus the GeneralsX fork's, with identical DLLs in identical directories - which is a question for whoever runs this branch successfully today. --- PatchNotes/splitscreen-bugfix-handoff4.md | 33 ++++++++++++++++++----- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/PatchNotes/splitscreen-bugfix-handoff4.md b/PatchNotes/splitscreen-bugfix-handoff4.md index 817514ad3b6..1605549254c 100644 --- a/PatchNotes/splitscreen-bugfix-handoff4.md +++ b/PatchNotes/splitscreen-bugfix-handoff4.md @@ -371,19 +371,38 @@ all**. The `!` prefix sorts them first and they shadow the directory listing. *This is also the caveat the #13 analysis raised about `340_ControlBarPro*` overriding `ControlBar.wnd` — the same archives, biting somewhere else first.* -**Problem 2 — display-mode enumeration faults. STILL OPEN.** +**Problem 2 — the build loads Windows' STUB D3D8 instead of DXVK. STILL OPEN, but localised.** Once the INI throw is gone, a first-chance access violation surfaces underneath: ``` generalszh!W3DDisplay::getDisplayModeCount+0xe [W3DDisplay.cpp @ 514] <- resolutions.Count() generalszh!W3DDisplay::init+0x357 [W3DDisplay.cpp @ 870] generalszh!GameClient::init+0x573 [GameClient.cpp @ 331] ``` -An AV is not a C++ throw, so `catch(...)` never sees it and the process dies silently with **no -crash record and 0 bytes of stderr** — which is exactly why this looked like "crashes at init with -no information" before the debugger. The DXVK `d3d8.dll` is byte-identical to the one the working -GeneralsX binary uses on the same box, and `dxvk.conf` was present, so it is not a stale DLL. -Unresolved: whether this branch's SDL3 display path is compatible with DXVK at all, or wants real -D3D8. **mirelle's own machine presumably clears this** — it is the first thing to ask her. +The mechanism is fully understood. `dx8wrapper.cpp:295` does `LoadLibrary("D3D8.DLL")`, and the +debugger's module list shows the process loading **`C:\WINDOWS\SysWOW64\D3D8.DLL`** — Windows' +stub — with DXVK's copy never loaded at all. The stub reports no usable adapter, so W3D enumerates +**zero** render devices, `WW3D::Get_Render_Device_Desc(0)` hands back a garbage reference and +`resolutions.Count()` faults. An AV is not a C++ throw, so `catch(...)` never sees it and the +process dies with **no crash record and 0 bytes of stderr** — which is exactly why this presented +as "crashes at init with no information" until a debugger was attached. + +What has been ruled out by measurement, so nobody repeats it: +* DXVK's `d3d8.dll` **is** present next to the exe (1,544,206 bytes) and is byte-identical to the + one the working GeneralsX binary uses on this same box. Not a missing or stale DLL. +* Its dependencies resolve — it imports DXVK's own `d3d9.dll` (also present) and the UCRT; + 32-bit `vulkan-1.dll` is in SysWOW64; the GPU is an RTX 5090. +* `dxvk.conf` present makes no difference. +* Mirroring the entire known-working run directory (shadercache, dxvk caches, `Data\`, every DLL) + and adding the retail `.big` files still reproduces it — so it is **the binary, not the + directory**. +* SDL3 does **not** harden the process-wide DLL search path; the bundled source uses + `LOAD_LIBRARY_SEARCH_SYSTEM32` only for `combase.dll`. That hypothesis is dead. + +So: the GeneralsX-fork binary loads DXVK from its own directory, and this branch's binary does not, +with identical DLLs in identical directories. The remaining question is **what differs in this +build's loader behaviour** — and it is worth asking mirelle first, because she runs this branch and +presumably gets a render device. If it also reproduces for her, this is an upstream bug: no +fallback and no diagnostic when device enumeration returns zero, just an AV. **Do not chase `CNC_GENERALS_ZH_PATH` on this branch — it does not exist here.** An unbounded grep over the whole tree returns zero hits; it is a GeneralsX-fork addition. This build finds its data From 782c609a6b41eadfb5636c8c7ed0fec2af13109f Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 04:37:46 +0300 Subject: [PATCH 19/42] fix(filesystem): StdLocalFileSystem recursed forever, dropping the parent path StdLocalFileSystem::getFileListInDirectory passed only the LEAF name of each subdirectory into its recursive call: AsciiString tempsearchstr(filenameStr.c_str()); getFileListInDirectory(tempsearchstr, originalDirectory, ...); so every level rebuilt the path as originalDirectory + leaf, losing the parent entirely. A bare leaf re-resolved against originalDirectory can land back on a directory already being walked, and the function then recurses until the stack is gone. Caught with a debugger on a real launch: 498 stacked frames of this one function, ending in c00000fd STACK OVERFLOW inside std::filesystem::directory_iterator. It kills the process about ten seconds in, AFTER DXVK has initialised and the swapchain exists, with no crash record and no stderr - so it presents as 'the game just vanishes'. The Win32 implementation of the identical function has always done this correctly (Win32LocalFileSystem.cpp): it concatenates currentDirectory + name + separator, keeping the full relative path as it descends. This makes the Std version behave the same, with the separator matching the platform. Found while trying to get a runtime verification pass for the splitscreen fixes; unrelated to them, and it blocks any run whose data directory has subdirectories. --- .../StdDevice/Common/StdLocalFileSystem.cpp | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Core/GameEngineDevice/Source/StdDevice/Common/StdLocalFileSystem.cpp b/Core/GameEngineDevice/Source/StdDevice/Common/StdLocalFileSystem.cpp index d47e473f4d2..b188752d8fb 100644 --- a/Core/GameEngineDevice/Source/StdDevice/Common/StdLocalFileSystem.cpp +++ b/Core/GameEngineDevice/Source/StdDevice/Common/StdLocalFileSystem.cpp @@ -269,7 +269,21 @@ void StdLocalFileSystem::getFileListInDirectory(const AsciiString& currentDirect std::string filenameStr = iter->path().filename().string(); if(iter->is_directory() && (strcmp(filenameStr.c_str(), ".") != 0 && strcmp(filenameStr.c_str(), "..") != 0)) { - AsciiString tempsearchstr(filenameStr.c_str()); + + // Build the child path from the CURRENT one. Passing only the leaf name dropped + // the parent, so each level re-resolved a bare name against originalDirectory - + // which lands back on a directory already being walked and recurses until the + // stack dies (observed: 498 frames of this function, then c00000fd). The Win32 + // implementation of this same function has always concatenated currentDirectory + // + name + separator; this is that behaviour. + AsciiString tempsearchstr; + tempsearchstr.concat(currentDirectory); + tempsearchstr.concat(filenameStr.c_str()); +#ifdef _WIN32 + tempsearchstr.concat('\\'); +#else + tempsearchstr.concat('/'); +#endif // recursively add files in subdirectories if required. getFileListInDirectory(tempsearchstr, originalDirectory, searchName, filenameList, searchSubdirectories); From 9220e8c755fc19dbb96a817fcf0f5d00bf4dc4ff Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 04:44:09 +0300 Subject: [PATCH 20/42] fix(filesystem): one unrepresentable filename killed the whole engine std::filesystem::path::string() throws std::system_error when the name will not convert to the narrow code page. StdLocalFileSystem::getFileListInDirectory called it unguarded on every entry it walked, so a single map or file with a non-ASCII name anywhere under the search root threw all the way out to the catch(...) in GameEngine::init and became 'Uncaught Exception during initialization'. Caught with a debugger on a real launch: std::_Throw_system_error_from_std_win_error std::_Convert_wide_to_narrow std::filesystem::path::string StdLocalFileSystem::getFileListInDirectory @ 242 StdLocalFileSystem::getFileListInDirectory @ 290 FileSystem::getFileListInDirectory MapCache::loadMapsFromDisk @ MapUtil.cpp:525 so it is the MAP SCAN that trips it, which means any user with an oddly named map cannot start the game at all - and the error names neither the file nor the directory. A file this engine cannot name is a file it cannot open either, so both loops now skip such an entry and keep walking, logging the directory. This only became reachable once the previous commit made the recursion actually descend into subdirectories. --- .../StdDevice/Common/StdLocalFileSystem.cpp | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/Core/GameEngineDevice/Source/StdDevice/Common/StdLocalFileSystem.cpp b/Core/GameEngineDevice/Source/StdDevice/Common/StdLocalFileSystem.cpp index b188752d8fb..8b02ad7c544 100644 --- a/Core/GameEngineDevice/Source/StdDevice/Common/StdLocalFileSystem.cpp +++ b/Core/GameEngineDevice/Source/StdDevice/Common/StdLocalFileSystem.cpp @@ -239,15 +239,23 @@ void StdLocalFileSystem::getFileListInDirectory(const AsciiString& currentDirect } while (!done) { - std::string filenameStr = iter->path().filename().string(); - if (!iter->is_directory() && iter->path().extension() == searchExt && - (strcmp(filenameStr.c_str(), ".") != 0 && strcmp(filenameStr.c_str(), "..") != 0)) { - // if we haven't already, add this filename to the list. - // a stl set should only allow one copy of each filename - AsciiString newFilename = iter->path().string().c_str(); - if (filenameList.find(newFilename) == filenameList.end()) { - filenameList.insert(newFilename); + // std::filesystem::path::string() THROWS if the name will not convert to the narrow + // code page, and one unconvertible filename anywhere under the search root used to + // take the whole engine down through the catch(...) in GameEngine::init. A file this + // engine cannot name is a file it cannot open either, so skip it and keep walking. + try { + std::string filenameStr = iter->path().filename().string(); + if (!iter->is_directory() && iter->path().extension() == searchExt && + (strcmp(filenameStr.c_str(), ".") != 0 && strcmp(filenameStr.c_str(), "..") != 0)) { + // if we haven't already, add this filename to the list. + // a stl set should only allow one copy of each filename + AsciiString newFilename = iter->path().string().c_str(); + if (filenameList.find(newFilename) == filenameList.end()) { + filenameList.insert(newFilename); + } } + } catch (const std::exception&) { + DEBUG_LOG(("StdLocalFileSystem::getFileListInDirectory - skipping unrepresentable filename in %s", fixedDirectory.c_str())); } iter++; @@ -266,7 +274,16 @@ void StdLocalFileSystem::getFileListInDirectory(const AsciiString& currentDirect done = iter == std::filesystem::directory_iterator(); while (!done) { - std::string filenameStr = iter->path().filename().string(); + std::string filenameStr; + try { + // same throwing conversion as the file loop above - skip, do not abort the walk + filenameStr = iter->path().filename().string(); + } catch (const std::exception&) { + DEBUG_LOG(("StdLocalFileSystem::getFileListInDirectory - skipping unrepresentable subdirectory in %s", fixedDirectory.c_str())); + iter++; + done = iter == std::filesystem::directory_iterator(); + continue; + } if(iter->is_directory() && (strcmp(filenameStr.c_str(), ".") != 0 && strcmp(filenameStr.c_str(), "..") != 0)) { From 7951c5cc0d1282dd399bad7417ebb197f98a292e Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 05:08:03 +0300 Subject: [PATCH 21/42] fix(cmdline): -map was ignored unless last, and its value re-parsed as a flag The dispatcher is 'arg += func(&argv[0]+arg, argc-arg)': num counts the flag plus every token after it, and the return value is how many tokens to consume. A parser that reads args[1] must therefore return 2. parseMapName got both halves wrong: * 'if (num == 2)' meant the map name was only read when '-map ' happened to be the LAST two tokens on the line. Put any flag after it and -map did nothing whatsoever, silently. * 'return 1' consumed only the flag, leaving the map name itself to be matched against the flag table on the next iteration. Harmless while no map name collides with an option name, but it is matching user data against the option table, which is not a property to rely on. parseFullVersion had the same return-count bug (guard was already correct). Audited every parser in the table: these two were the only ones that read args[1] and could return 1. The parsers that return either 1 or 2 (-splitscreendev, -xres, -yres, -replay, -jobs, -jumptoframe) are correct - they take an OPTIONAL value and return the count they actually consumed. Fixed in both trees since the defect is identical and is not splitscreen work. NOTE: the Generals/ target does not build on this branch (documented in handoff2 5.4), so that copy is unverified by compilation - the change is the same two lines as the GeneralsMD one, which is built and verified. --- .../Code/GameEngine/Source/Common/CommandLine.cpp | 8 +++++++- .../Code/GameEngine/Source/Common/CommandLine.cpp | 12 +++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Generals/Code/GameEngine/Source/Common/CommandLine.cpp b/Generals/Code/GameEngine/Source/Common/CommandLine.cpp index cd2284943a5..eb77f973372 100644 --- a/Generals/Code/GameEngine/Source/Common/CommandLine.cpp +++ b/Generals/Code/GameEngine/Source/Common/CommandLine.cpp @@ -383,9 +383,11 @@ Int parseNoWin(char *args[], int) Int parseFullVersion(char *args[], int num) { + // consumed args[1], so consume 2 tokens - otherwise the value is re-matched as a flag if (TheVersion && num > 1) { TheVersion->setShowFullVersion(atoi(args[1]) != 0); + return 2; } return 1; } @@ -400,10 +402,14 @@ Int parseNoShadows(char *args[], int) Int parseMapName(char *args[], int num) { - if (num == 2) + // See the GeneralsMD copy of this function: `num == 2` meant -map was only honoured when it + // was the last two tokens, and returning 1 left the map name to be re-matched against the + // flag table. num counts the flag plus the rest; the return is how many tokens to consume. + if (num > 1) { TheWritableGlobalData->m_mapName.set( args[ 1 ] ); ConvertShortMapPathToLongMapPath(TheWritableGlobalData->m_mapName); + return 2; } return 1; } diff --git a/GeneralsMD/Code/GameEngine/Source/Common/CommandLine.cpp b/GeneralsMD/Code/GameEngine/Source/Common/CommandLine.cpp index cd7a4a85b64..34502ea22bb 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/CommandLine.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/CommandLine.cpp @@ -383,9 +383,11 @@ Int parseNoWin(char *args[], int) Int parseFullVersion(char *args[], int num) { + // consumed args[1], so consume 2 tokens - otherwise the value is re-matched as a flag if (TheVersion && num > 1) { TheVersion->setShowFullVersion(atoi(args[1]) != 0); + return 2; } return 1; } @@ -400,10 +402,18 @@ Int parseNoShadows(char *args[], int) Int parseMapName(char *args[], int num) { - if (num == 2) + // Two bugs here, both silent: + // * `num == 2` meant the map name was only read when "-map " happened to be the + // LAST two tokens; put any flag after it and -map did nothing at all. + // * returning 1 consumed only the flag, so the map name itself was then matched against + // the flag table as if it were another option. + // num counts the flag plus everything after it, and the return value is how many tokens to + // consume - so a parser that reads args[1] must return 2. + if (num > 1) { TheWritableGlobalData->m_mapName.set( args[ 1 ] ); ConvertShortMapPathToLongMapPath(TheWritableGlobalData->m_mapName); + return 2; } return 1; } From 5c93bff587e80429f8e6e797a2032139cee4c270 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 05:57:00 +0300 Subject: [PATCH 22/42] docs: correct handoff4 - the mods were never the INI problem Excluding the five mod archives made the INI_CANT_OPEN_FILE throw disappear, and this document blamed them for it. That was a coincidence of load order and the conclusion was wrong. The actual cause was the StdLocalFileSystem recursion bug fixed in 782c609a6: loadFileDirectory finds Data\INI\Weather through the recursive directory walk, and the walk was resolving bare leaf names against the wrong parent, so it read zero files and threw. With the recursion fixed the game loads with all five mod archives present - verified in-game on the operator's own modded install, 8-seat splitscreen running. Recording this because the wrong version told the next person to strip a user's mods, which would have been bad advice for a symptom that no longer exists. --- PatchNotes/splitscreen-bugfix-handoff4.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/PatchNotes/splitscreen-bugfix-handoff4.md b/PatchNotes/splitscreen-bugfix-handoff4.md index 1605549254c..63c5c2da56e 100644 --- a/PatchNotes/splitscreen-bugfix-handoff4.md +++ b/PatchNotes/splitscreen-bugfix-handoff4.md @@ -356,20 +356,20 @@ existing run directory. Dropping the new exe beside the mismatched ones gives `0 Debugging Tools for Windows were installed and the exception caught first-chance. **Neither problem is in any fix from this round; both reproduce on the untouched baseline.** -**Problem 1 — mod `.big` files break INI loading. SOLVED.** +**Problem 1 — INI loading threw. SOLVED, and NOT the mods.** ``` generalszh!INI::loadFileDirectory+0x15a [Core/.../INI/INI.cpp @ 222] <- throw INI_CANT_OPEN_FILE generalszh!GameEngine::init+0x30e [GameEngine.cpp @ 488] <- "Data\INI\Weather" ``` -`loadFileDirectory` throws when it reads **zero** files. Line 488 is `Data\INI\Weather`; the three -sibling loads on 485-487 (`Default\Water`, `Water`, `Default\Weather`) all succeed, and -`Data\INI\Weather.ini` **does** exist in retail `INIZH.big`. The difference is the mod archives in -the test install: `!HotkeysLeikezeIndicatorsZH.big`, `!HotkeysLeikezeZH.big`, -`340_ControlBarProZH.big`, `340_ControlBarPro1440ZH.big`, `340_ControlBarPro-Fix1440ZH.big`. -Running against the 20 retail `.big` files **with those five excluded produces no C++ throw at -all**. The `!` prefix sorts them first and they shadow the directory listing. -*This is also the caveat the #13 analysis raised about `340_ControlBarPro*` overriding -`ControlBar.wnd` — the same archives, biting somewhere else first.* +`loadFileDirectory` throws when it reads **zero** files. It was first observed that excluding the +five mod archives (`!HotkeysLeikeze*`, `340_ControlBarPro*`) made the throw go away, and this +document previously blamed them. **That was wrong** - it was a coincidence of load order. + +The real cause is the `StdLocalFileSystem` recursion bug fixed in `782c609a6`: +`loadFileDirectory` locates those files *through* the recursive directory walk, and the walk was +resolving bare leaf names against the wrong parent. Once the recursion was fixed, the game loads +**with all five mod archives present**, verified on the operator's own modded install. Do not +strip anyone's mods over this. **Problem 2 — the build loads Windows' STUB D3D8 instead of DXVK. STILL OPEN, but localised.** Once the INI throw is gone, a first-chance access violation surfaces underneath: From 0d2fbc5c9b74ab0b5b4a04c16adc07d83dd13f2e Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:14:22 +0300 Subject: [PATCH 23/42] probe(#10): instrument the cursor-hint gates for a pad seat User-confirmed #10 is NOT fixed and my diagnosis was wrong: the pad seat's cursor stays a plain arrow with ONE unit selected AND with two or more. The isLocallyControlled defect predicted an asymmetry (broken at 1, fine at 2+); there is none, so something upstream is killing the hint entirely. Prime suspect is the early return: m_isScrolling, m_isSelecting and m_mouseMode are single-instance InGameUI members, not SeatUIContext fields, so seat 0's state suppresses hint generation for every other seat. That was identified during the verification sweep and deliberately logged as a separate work item rather than fixed - which now looks like the wrong call. GX_CURSORPROBE logs, per hint message: the acting seat, the message type, m_isScrolling/m_isSelecting/m_mouseMode, whether the early return fired, and inside MOUSEMODE_DEFAULT whether underWindow or the srcObj ownership test sent it to ARROW. That distinguishes 'never runs' from 'runs and picks ARROW', which is the fork the fix depends on. --- .../GameEngine/Source/GameClient/InGameUI.cpp | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index 3129168c85e..5205a392149 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -3112,8 +3112,22 @@ void InGameUI::createMouseoverHint( const GameMessage *msg ) */ void InGameUI::createCommandHint( const GameMessage *msg ) { + // Splitscreen probe (#10): the pad seat's cursor never changes shape at ALL - not the + // "one unit selected" asymmetry that isLocallyControlled would produce. So the question is + // which gate kills it. m_isScrolling/m_isSelecting/m_mouseMode are single-instance members, + // not per-seat, so seat 0's state can silently suppress every other seat's hint. + if( getenv("GX_CURSORPROBE") != nullptr ) + seatLog("[GXCUR] enter seat=%d msgType=%d scrolling=%d selecting=%d mouseMode=%d mousedOver=%d", + m_activeSeat, (Int)msg->getType(), (Int)m_isScrolling, (Int)m_isSelecting, + (Int)m_mouseMode, (Int)m_seatContexts[m_activeSeat].m_mousedOverDrawableID); + if (m_isScrolling || m_isSelecting || TheRecorder->getMode() == RECORDERMODETYPE_PLAYBACK) + { + if( getenv("GX_CURSORPROBE") != nullptr ) + seatLog("[GXCUR] seat=%d EARLY-RETURN (scrolling=%d selecting=%d)", + m_activeSeat, (Int)m_isScrolling, (Int)m_isSelecting); return; + } const Drawable *draw = TheGameClient->findDrawableByID(m_seatContexts[m_activeSeat].m_mousedOverDrawableID); GameMessage::Type t = msg->getType(); @@ -3205,8 +3219,14 @@ void InGameUI::createCommandHint( const GameMessage *msg ) // i.e. seat 0's, so a pad seat with exactly one of ITS OWN units selected failed this // test and had its cursor pinned to ARROW for as long as that selection lasted. Ask // the acting seat's player instead. Identity in single view. + if( getenv("GX_CURSORPROBE") != nullptr ) + seatLog("[GXCUR] seat=%d MOUSEMODE_DEFAULT underWindow=%d srcObj=%d srcOwned=%d t=%d", + m_activeSeat, (Int)underWindow, (Int)(srcObj != nullptr), + (Int)(srcObj ? srcObj->isControlledByPlayer(getCommandActingPlayer()) : 0), (Int)t); if (underWindow || (srcObj && !srcObj->isControlledByPlayer(getCommandActingPlayer()))) { + if( getenv("GX_CURSORPROBE") != nullptr ) + seatLog("[GXCUR] seat=%d -> ARROW (underWindow=%d)", m_activeSeat, (Int)underWindow); setMouseCursor(Mouse::ARROW); return; } From 2039949b62b74ff3e0e979d7c960d18bbca7484b Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:34:38 +0300 Subject: [PATCH 24/42] docs: handoff4 - first runtime verification; #7 passes, #8 and #10 are one bug Real pad on a real seat, Release build, operator-driven. #7 VERIFIED. Both halves confirmed by the operator: the pad seat's lasso draws and it vanishes rather than freezing when seat 0 pre-empts the drag. #10 NOT FIXED, and the diagnosis recorded here was wrong. It predicted an asymmetry - broken with one unit selected, fine with two or more. There is no asymmetry; the cursor is a plain arrow either way. The probe shows the single-instance scrolling/selecting early return is NOT responsible (scrolling=0 selecting=0), the window gate is not blocking (underWindow=0), and the isControlledByPlayer fix genuinely works (srcOwned=1) - but mousedOver=0 on every sample, so there is never a hovered drawable to decide a shape from. #8 REPRODUCED with the probe, and handoff3's stated cause is refuted by measurement: isPoint=1 and the acting seat resolves correctly, willSelect=0 means the region yielded zero drawables, and no [GXPICK] line shows a window blocking the pad seat - the only refusals belong to seat 0. Both are the same defect: SelectionXlat builds the mouseover hint from pickDrawable, so an empty pick gives no hover (no cursor shape) and no selection. Leading hypothesis is a coordinate-space mismatch between the display-absolute click region and the view-relative pick ray; that is the first thing to establish, not to assume. --- PatchNotes/splitscreen-bugfix-handoff4.md | 48 +++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/PatchNotes/splitscreen-bugfix-handoff4.md b/PatchNotes/splitscreen-bugfix-handoff4.md index 63c5c2da56e..571cf9ac0a0 100644 --- a/PatchNotes/splitscreen-bugfix-handoff4.md +++ b/PatchNotes/splitscreen-bugfix-handoff4.md @@ -444,6 +444,54 @@ that diagnosis is wrong and should be re-opened, not patched around. * **#8.** Run with `GX_CLICKPROBE=1` and click (not drag) with the pad, then read `splitscreen_input.log` for `[GXPICK]` / `[GXCLICK]` and follow the decision table in §3. +## 6d. RUNTIME RESULTS — first real verification, on a pad + +Run on the operator's box: Release build, `-splitscreendev 3`, a real DualSense claiming a seat +(`dev=6`, a non-negative SDL joystick id), 4 seats live with players, viewports and bars. + +**#7 lasso — VERIFIED WORKING.** Operator-confirmed both halves: the pad seat's drag box now +draws (it never did), and when seat 0 presses mid-drag the pad's box **vanishes** rather than +freezing — which is the latent stuck-lasso bug the fix exposed and handled. + +**#10 cursor — NOT FIXED. The diagnosis in this document was wrong.** +Predicted signature was an asymmetry: stuck ARROW with exactly one unit selected, normal with two +or more. Operator reports the plain arrow with **one AND with two or more** - no asymmetry at all. +The `GX_CURSORPROBE` trace shows why: +``` +[GXCUR] enter seat=3 msgType=... scrolling=0 selecting=0 mouseMode=0 mousedOver=0 +[GXCUR] seat=3 MOUSEMODE_DEFAULT underWindow=0 srcObj=1 srcOwned=1 +``` +* `scrolling=0 selecting=0` - the single-instance `m_isScrolling`/`m_isSelecting` early return is + NOT the cause (that was the next suspect; it is cleared). +* `underWindow=0` - the window gate is not blocking. +* `srcOwned=1` - the `isControlledByPlayer(getCommandActingPlayer())` fix **works**; it just was + not the operative defect. +* **`mousedOver=0`, on every single sample** - the seat never has a moused-over drawable, so + `createCommandHint`'s `draw` is null and no shape decision can be made. + +**#8 click-select — REPRODUCED, and handoff3's cause refuted by measurement.** +``` +[GXCLICK] seat=2 actingSeat=2 isPoint=1 region=(532,751)-(532,751) willSelect=0 +``` +`isPoint=1` and the acting seat resolves correctly, so routing is fine; `willSelect=0` means the +region yielded **zero drawables**. Every `[GXPICK]` line in the session reports `actingSeat=0` and +`windowUnderCursor=null`; the only refusals are seat 0's (`id=3828/3829`). **No window blocks the +pad seat's pick.** + +### #8 and #10 ARE THE SAME BUG +`SelectionXlat.cpp:453` builds the mouseover hint from +`getCommandActingView()->pickDrawable(...)`. If picking returns nothing for a pad seat then: +* no `MSG_MOUSEOVER_DRAWABLE_HINT` -> `m_mousedOverDrawableID` stays invalid -> **cursor never + changes shape (#10)** +* the same empty result on a click -> **nothing selects (#8)** + +Treat them as one defect. **Leading hypothesis, not yet confirmed:** a coordinate-space mismatch. +The click region arrives in FULL-DISPLAY pixels (`(532,751)` on a 1600x900 display, inside seat 2's +`vp=(0,450 800x450)`), while `W3DView::getPickRay` subtracts `m_originX/m_originY` and divides by +`getWidth()/getHeight()`. Whether the incoming region is already view-relative or still +display-absolute is the thing to establish first - and it would explain why a projected rect +(drag) behaves differently from a point ray-cast (click). + ## 7. Process notes * `getCommandActingSeat()` is at **global scope**, not in namespace `rts`. Writing From 268c82879a5b0b8667b045f30e06f7a0177923e2 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:42:41 +0300 Subject: [PATCH 25/42] docs: session dropoff 2026-08-06 Records where the branch stands, what runs, and the one unanswered question that decides whether #10 is fixed or still broken. Nothing is pushed anywhere - no remote, no fork, no PR. The Windows box is kept in sync by a git bundle over scp. --- PatchNotes/DROPOFF_2026-08-06.md | 130 +++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 PatchNotes/DROPOFF_2026-08-06.md diff --git a/PatchNotes/DROPOFF_2026-08-06.md b/PatchNotes/DROPOFF_2026-08-06.md new file mode 100644 index 00000000000..c1fc67f07fc --- /dev/null +++ b/PatchNotes/DROPOFF_2026-08-06.md @@ -0,0 +1,130 @@ +# Session dropoff — 2026-08-06 + +Branch `splitscreen-documents`, working from mirelle's request: run the agent on +`githubawn/GeneralsGameCode`, follow `PatchNotes/` handoffs, finish the remaining tasks, PR when +"complete and verified". + +## Read this first + +`PatchNotes/splitscreen-bugfix-handoff4.md` — everything below is summarised there in detail, +including §6c (per-finding test recipe) and §6d (runtime results). + +## Where the code is — NOTHING IS PUSHED + +| | | +|---|---| +| Mac clone | `/Users/administrator/GeneralsSplit` (branch `splitscreen-documents`) | +| Windows build | `C:\dev\GenSplit` — kept in sync by a **git bundle over scp**, no remote | +| Remote | **none.** No fork, no push, no PR. Operator gated this deliberately. | + +Commit identity is set locally in both clones to `wh1ter0se` — the clone's `https://` remote does +NOT match the `includeIf hasconfig:remote.*.url:git@github-personal:*/*` rule, so without that it +silently commits as the archive-only RugPullBot account. + +## What was done + +**All 12 open findings from handoff3 are implemented.** Only #12 is untouched, which handoff3 +itself deferred pending a probe run. + +**Four real bugs were found that had nothing to do with the 12**, two of which were why the game +would not start at all: + +* `782c609a6` — `StdLocalFileSystem::getFileListInDirectory` passed only the **leaf name** into its + recursive call, dropping the parent path, so it re-walked directories until the stack died. + Caught as **498 stacked frames** ending in `c00000fd`. The Win32 twin of that function always + concatenated the full path; they now match. +* `9220e8c75` — `std::filesystem::path::string()` **throws** on a name that will not convert to the + narrow code page, called unguarded on every entry walked. Fired from `MapCache::loadMapsFromDisk`, + so **one oddly-named map made the game unstartable**. The operator's map folder genuinely has + these (`(aod) l?bben, germany`, `?p_unify a`, `Pearl Harbor`). +* `7951c5cc0` — `parseMapName` ignored `-map` unless it was the last two tokens AND consumed the + wrong token count, so the map name was re-matched against the flag table. Same return-count bug + in `parseFullVersion`. Fixed in both trees; every other parser was audited and is correct. + +**IMPORTANT CORRECTION recorded in handoff4:** an earlier conclusion that the operator's mod +archives (`!HotkeysLeikeze*`, `340_ControlBarPro*`) broke INI loading was **WRONG**. That was a +coincidence of load order; the real cause was the recursion bug. With `782c609a6` in, the game runs +**with all five mods present** — verified in-game. Do not tell anyone to strip their mods. + +## Runtime status — it runs, and 8-seat splitscreen works + +Verified on the operator's box, Release build: main menu → SOLO PLAY → SKIRMISH auto-starts an +8-player match; `activeSeats=8`, every seat with its own player, viewport and control bar, AI +armies building. ControlBar Pro mod active. A real DualSense claims a seat (`dev=6`). + +| finding | runtime status | +|---|---| +| **#7 lasso** | **VERIFIED WORKING** — operator confirmed the pad's box draws, and vanishes (not freezes) when seat 0 pre-empts | +| **#10 cursor** | **NOT FIXED as diagnosed.** See below | +| **#8 click-select** | **REPRODUCED**, handoff3's cause refuted by measurement | +| everything else | implemented, compiled, **not runtime-verified** | + +### #8 and #10 — the open thread, and the ONE question that splits it + +Probe data (`GX_CURSORPROBE`, `GX_CLICKPROBE` — both now OFF; set the env var to re-enable): + +``` +[GXCUR] enter seat=3 scrolling=0 selecting=0 mouseMode=0 mousedOver=0 +[GXCUR] seat=3 MOUSEMODE_DEFAULT underWindow=0 srcObj=1 srcOwned=1 +[GXCLICK] seat=2 actingSeat=2 isPoint=1 region=(532,751)-(532,751) willSelect=0 +``` + +Established: +* the single-instance `m_isScrolling`/`m_isSelecting` early return is **NOT** the cause (cleared) +* the window gate is **NOT** blocking (`underWindow=0`; no `[GXPICK]` refusal on a pad seat — the + only refusals belong to seat 0). **handoff3's stated cause for #8 is refuted by measurement.** +* `srcOwned=1` — the `isControlledByPlayer(getCommandActingPlayer())` fix **works**, it was just + not the operative defect +* `mousedOver=0` on every sample — the pad seat never gets a hovered drawable + +`SelectionXlat.cpp:453` builds the mouseover hint from `pickDrawable`, so an empty pick gives both +no cursor shape (#10) and no selection (#8) — **treat them as one defect.** + +**Then the operator reported: hover/pick works on ENEMY units and buildings, but not on his own.** +That kills the coordinate-space hypothesis (picking works spatially) and points at ownership. +Note `contextCommandForNewSelection` (`SelectionInfo.cpp`) already carries a splitscreen fix for +exactly this class, whose comment says a pad seat "could not select anything at all, anywhere, +ever" — so some *other* ownership test still resolves against seat 0. + +**UNANSWERED QUESTION — ask this first, the answer changes everything:** +> On the pad seat with a unit selected, hovering **open terrain** — do you get the **move** cursor, +> or still a plain arrow? + +* move cursor → **#10 is actually fixed**, and "no special cursor over my own units" is correct + stock behaviour, not a bug. Only #8 remains. +* plain arrow → still broken, keep digging on the pick path. + +Also unconfirmed: whether **drag-selecting** a box around your own unit selects it while +**clicking** it does not. That asymmetry is the sharpest remaining clue. + +## Test rig on the Windows box + +| thing | what it is | +|---|---| +| `C:\dev\gxdata` | Release run dir — 32-bit DXVK, retail bigs + the 5 mods, `Data\` | +| `C:\dev\gxdbg` | Debug run dir (`-file` auto-start works there; Release compiles it out) | +| `C:\dev\lib-safekill.ps1` | `Stop-TestGame`/`Get-TestGame` — match by **PATH** | +| `C:\dev\gxdrive.ps1` | the operator's proven click driver, repointed at `gxdata` | +| tasks | `GXSplit` (launch), `GXShot` (per-window capture), `GXDesk` (desktop capture), `GXDrive2` (driver) | +| Mac helpers | `scratchpad/winrun.sh` (base64 `-EncodedCommand`), `pushwin.sh` (bundle → Windows) | + +Menu coordinates at `-xres 1600 -yres 900`, **client** coords (the driver adds the origin itself): +SOLO PLAY `(1285,202)`, SKIRMISH `(1285,441)`. Hover before each click. + +## Traps that cost real time here — do not re-learn these + +* **NEVER `Get-Process generalszh | Stop-Process`.** The operator's own game runs from + `C:\dev\GeneralsX-run` under the same exe name — this killed his session mid-match. Match on + `ExecutablePath`. There are also ~9 unkillable husks (threads=0) that break any name-based tool. +* **32-bit vs 64-bit.** GeneralsX is **x64**; this branch builds **x86**. Feeding it the x64 DXVK + from `GeneralsX-run` gives `ERROR_BAD_EXE_FORMAT`, silently falls through to Windows' stub + `d3d8.dll`, zero render devices, and an AV in `W3DDisplay::getDisplayModeCount`. + Use `C:\dev\dxvk\dxvk-2.6\x32\`. +* **`CNC_GENERALS_ZH_PATH` does not exist on this branch** (unbounded grep, zero hits) — it is a + GeneralsX-fork addition. This build finds data via the **working directory** only. +* `Start-ScheduledTask` will **not** start a second instance while one is running — a long `Hold` + silently blocks the next run. Stop the task first. +* The exe SHA moves on **every** rebuild (`__TIME__`/`__DATE__` in `WinMain.cpp:986`). Prove a + change landed with `dumpbin /SYMBOLS` on the static lib, not the exe hash. +* Use VS BuildTools' cmake, not winlibs' — the latter has no CA bundle and the SDL3 FetchContent + dies with cURL status 60. From 6afadc1e2166138186dc268030658a2c64c17968 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:35:49 +0300 Subject: [PATCH 26/42] splitscreen: a pad seat could never draw the move cursor, whatever the hint said #10 again, and the previous diagnosis was wrong in an instructive way. It kept asking which gate in the hint chain killed the cursor. None of them did. The shape decision was always arriving correctly and the RENDERER could not draw it. cursorTextureFileName picked the filename off info->numFrames: <=1 meant the unnumbered form, otherwise %04d.tga. numFrames is ALWAYS 1, because retail Mouse.ini declares no frame count for any cursor - an unbounded grep for `Frames` over the shipped Data\INI\Mouse.ini returns zero hits, the only anim key present being `Directions = 8` on Scroll. So this always asked for the unnumbered name. The shipped art does not agree. Scanning every .big in a retail ZH install: Art\Textures\sccmove0000.dds .. sccmove0020.dds 21 frames, NO plain file Art\Textures\sccscroll0000.dds .. 4 frames, NO plain file Art\Textures\sccpointer.dds plain, exists Art\Textures\sccattack.dds plain AND numbered so the lookup for SCCMove missed, WW3D returned its 128x128 missing-texture placeholder, the size guard correctly refused it as not cursor-shaped, and drawSeatCursor substituted ARROW. Move and Scroll could not render for any seat, ever, regardless of what createCommandHint decided. That is also why the only shapes ever seen on a pad seat were the arrow and the attack cursor: SCCPointer and SCCAttack are the two that ship unnumbered. The "cursor works on enemies but not on my own units" report was never about picking at all - Select has no texture art whatsoever, only a .ani. Fix: ask the art rather than the INI. Measure the unnumbered file, take it if it measures cursor-shaped, else use the numbered one; resolve once per cursor state and cache. Left UNRESOLVED rather than latching a guess when neither measures, because cursor textures load on demand and the first frames a seat cursor is drawn can measure nothing - and re-point the cached Image when it does resolve, or it keeps drawing from the name we just established was wrong. Player 1 is untouched: it runs RM_WINDOWS and draws the OS .ani cursors from Data\Cursors, never these textures. Still open, logged not fixed: 27 of 37 cursor states have no texture art at all (AttackMove, Select, Enter, Waypoint, ...) and exist only as .ani and .W3D. A pad seat still falls back to the arrow for those. That needs a different art source and is its own change. Co-Authored-By: Claude Opus 5 --- .../GameClient/W3DSeatCursorRenderer.cpp | 87 ++++++++++++++++--- 1 file changed, 77 insertions(+), 10 deletions(-) diff --git a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DSeatCursorRenderer.cpp b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DSeatCursorRenderer.cpp index 1eaf865db9a..9d90781d1b9 100644 --- a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DSeatCursorRenderer.cpp +++ b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DSeatCursorRenderer.cpp @@ -102,15 +102,17 @@ static Color seatTintColor(const LocalSeat* seat) // falls back to the arrow, which is always preferable to a coloured rectangle. static const Int CURSOR_MAX_REASONABLE_SIZE = 64; +static Bool isPlausibleCursorSize(Int w, Int h) +{ + return w > 0 && h > 0 && w <= CURSOR_MAX_REASONABLE_SIZE && h <= CURSOR_MAX_REASONABLE_SIZE; +} + static Bool isPlausibleCursorImage(const Image *image) { if (image == nullptr) return FALSE; - const Int w = image->getImageWidth(); - const Int h = image->getImageHeight(); - - return w > 0 && h > 0 && w <= CURSOR_MAX_REASONABLE_SIZE && h <= CURSOR_MAX_REASONABLE_SIZE; + return isPlausibleCursorSize( image->getImageWidth(), image->getImageHeight() ); } // Resolve the art for a cursor state into a drawable Image. @@ -128,14 +130,14 @@ static Bool isPlausibleCursorImage(const Image *image) // The file W3DMouse::loadD3DCursorTextures would load for this cursor state. That texture IS // player 1's cursor: SetCursorProperties hands its surface straight to D3D, which draws it at the // surface's own pixel size. Anything that wants to match player 1's cursor has to measure it. -static AsciiString cursorTextureFileName(const CursorInfo *info, Int frame) +static AsciiString cursorTextureFileName(const CursorInfo *info, Int frame, Bool numbered) { AsciiString file; - if (info->numFrames <= 1) - file.format("%s.tga", info->textureName.str()); // single frame, no suffix + if (numbered) + file.format("%s%04d.tga", info->textureName.str(), frame); // animated: SCCMove0000.tga else - file.format("%s%04d.tga", info->textureName.str(), frame); // animated + file.format("%s.tga", info->textureName.str()); // single frame, no suffix return file; } @@ -167,6 +169,61 @@ static Bool measureCursorTexture(const AsciiString &file, ICoord2D *sizeOut) return measured; } +// Which of the two naming conventions this cursor's art actually uses. +// +// numFrames is the WRONG thing to decide it on, and that is what pinned every pad seat to an +// arrow. Retail Mouse.ini declares no frame count for ANY cursor - an unbounded grep for +// `Frames` over the shipped Data\INI\Mouse.ini returns zero hits - so numFrames is always its +// default of 1 and this always asked for the unnumbered name. But the shipped art does not +// follow that: `sccmove` exists ONLY as sccmove0000.dds..sccmove0020.dds with no unnumbered +// file, and so does `sccscroll`. The lookup missed, WW3D handed back its 128x128 missing-texture +// placeholder, the size guard below correctly rejected that as not-cursor-shaped, and +// drawSeatCursor silently substituted ARROW. So MOVE and SCROLL could never draw, for any seat, +// no matter what the hint chain decided - which is most of "the pad seat's cursor never changes +// shape". SCCPointer and SCCAttack ship unnumbered, which is exactly why those two were the only +// shapes anyone ever saw on a pad seat. +// +// So ask the art, not the INI: take the unnumbered file if it measures like a cursor, else the +// numbered one. Player 1 is unaffected either way - it runs RM_WINDOWS and draws the OS .ani +// cursors from Data\Cursors, never these textures. +// +// Resolved once per cursor state and cached: this runs per seat per frame. +static Bool cursorUsesNumberedArt(const CursorInfo *info, Int cursorType) +{ + enum { UNRESOLVED = 0, UNNUMBERED, NUMBERED }; + static Int s_naming[Mouse::NUM_MOUSE_CURSORS]; + + if (cursorType < 0 || cursorType >= Mouse::NUM_MOUSE_CURSORS) + return FALSE; + + if (s_naming[cursorType] == UNRESOLVED) + { + ICoord2D size; + const AsciiString plain = cursorTextureFileName( info, 0, FALSE ); + if (measureCursorTexture( plain, &size ) && isPlausibleCursorSize( size.x, size.y )) + { + s_naming[cursorType] = UNNUMBERED; + } + else + { + const AsciiString numbered = cursorTextureFileName( info, 0, TRUE ); + if (measureCursorTexture( numbered, &size ) && isPlausibleCursorSize( size.x, size.y )) + s_naming[cursorType] = NUMBERED; + // Neither measured: leave UNRESOLVED so we retry. Cursor textures load on demand and + // may not be resident the first frames a seat cursor is drawn; latching a guess here + // would make the miss permanent. + } + } + + return s_naming[cursorType] == NUMBERED; +} + +// The file to load for this cursor state and frame, with the naming convention resolved. +static AsciiString resolvedCursorTextureFileName(const CursorInfo *info, Int cursorType, Int frame) +{ + return cursorTextureFileName( info, frame, cursorUsesNumberedArt( info, cursorType ) ); +} + static const Image *findCursorImage(Int cursorType, Int frame, const CursorInfo **infoOut) { static const Int CURSOR_SIZE_FALLBACK = 32; // only if the texture cannot be measured @@ -206,7 +263,7 @@ static const Image *findCursorImage(Int cursorType, Int frame, const CursorInfo if (s_cache[cursorType][frame] == nullptr || !s_cacheSizeKnown[cursorType][frame]) { - const AsciiString file = cursorTextureFileName( info, frame ); + const AsciiString file = resolvedCursorTextureFileName( info, cursorType, frame ); Image *image = s_cache[cursorType][frame]; if (image == nullptr) @@ -227,6 +284,16 @@ static const Image *findCursorImage(Int cursorType, Int frame, const CursorInfo s_cache[cursorType][frame] = image; } + else if (image->getName() != file) + { + // The naming convention resolved (or re-resolved) since this Image was built. That is + // the normal path, not an edge case: cursor textures load on demand, so the first + // frames a seat cursor is drawn can measure nothing at all, and cursorUsesNumberedArt + // deliberately stays UNRESOLVED rather than latching a guess. Re-point the cached + // Image, or it keeps drawing from the name we have just established is wrong. + image->setName( file ); + image->setFilename( file ); + } // Measure the real cursor texture rather than assuming a size - Mouse.ini declares none. // Keep retrying until it succeeds: the texture is not necessarily resident on the first @@ -303,7 +370,7 @@ static void drawSeatCursor(const LocalSeat* seat) if (info != nullptr && !info->textureName.isEmpty()) { ICoord2D textureSize; - if (measureCursorTexture( cursorTextureFileName( info, currentCursorFrame( info ) ), &textureSize ) + if (measureCursorTexture( resolvedCursorTextureFileName( info, seat->m_cursor.cursorType, currentCursorFrame( info ) ), &textureSize ) && textureSize.x > 0 && textureSize.x <= CURSOR_MAX_REASONABLE_SIZE && textureSize.y > 0 && textureSize.y <= CURSOR_MAX_REASONABLE_SIZE) { From 0a17bb649bcafbbece84764d635247615bfdb2a6 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:36:07 +0300 Subject: [PATCH 27/42] probe(#8): the point pick is answered against seat 0's visibility, not the acting seat's Instrumentation, not a fix. Default OFF; set GX_PICKALL=1. "Click does not select, drag does" has exactly two candidate gates, because both go through iterateDrawablesInRegion from the same call site with the same struct and differ only in branch. The rect branch walks TheGameClient->firstDrawable() and projects each. The point branch delegates to pickDrawable, which adds (a) the window gate - already cleared by measurement, underWindow=0 - and (b) castRay(raytest, testAll=false, pickType). (b) filters on Is_Really_Visible(). That flag is pure RENDER RESIDUE: W3DScene.cpp:534-655 Visibility_Check rewrites it for every render object from THIS view's camera frustum and THIS view's player's vision (seatOwnerFilterHidesObject) W3DScene.cpp:1466 Visibility_Checked is cleared straight after, so the pass genuinely re-runs per view Display.cpp:163 drawViews walks the view list head to tail Display.cpp:109 attachView PREPENDS so seat 0's view - attached first at InGameUI.cpp:1442, the only other attachView being the seat>0 one - sits at the tail and is drawn LAST, and seat 0's visibility set is the one standing when the message stream is translated (GameClient.cpp:820 draws, GameEngine.cpp:929-930 then propagates). A pad seat's point pick is therefore answered against what SEAT 0 can see. Its own units, framed by its own camera somewhere seat 0 is not looking, are frustum-culled or shrouded away and the ray never tests them. The drag path never reads the flag. That is the reported asymmetry exactly, and it predicts seat 0 keeps working - which it does. Not yet proven, hence a probe rather than a fix. GX_PICKALL=1 bypasses the filter: if a pad seat's click-select starts working with it set, the diagnosis is confirmed; if it does not, the cause is elsewhere and this should be reverted rather than built on. Bypassing is NOT the fix either way - it would let a seat pick units hidden in its own fog. The real fix is to evaluate visibility for the PICKING seat instead of reusing the last render pass's answer. Also retracts a load-bearing piece of handoff4's evidence: "mousedOver=0 on every sample" is not diagnostic. InGameUI.cpp:3064 sets m_mousedOverDrawableID to INVALID in the terrain branch, so 0 is the CORRECT value whenever the cursor is over ground - which is what was being sampled. Co-Authored-By: Claude Opus 5 --- .../Source/W3DDevice/GameClient/W3DView.cpp | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp index c6ac635bb77..6dcb52a79a3 100644 --- a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp +++ b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp @@ -2595,7 +2595,27 @@ Drawable *W3DView::pickDrawable( const ICoord2D *screen, Bool forceAttack, PickT //Don't check against translucent or hidden objects RayCollisionTestClass raytest(lineseg,&result,COLL_TYPE_ALL,false,false); - if( W3DDisplay::m_3DScene->castRay( raytest, false, (Int)pickType ) ) + // Splitscreen probe (#8), instrumentation only - default OFF, set GX_PICKALL=1 to enable. + // + // castRay's testAll=false makes the point pick consider only render objects flagged + // Is_Really_Visible(). That flag is pure RENDER RESIDUE: RTS3DScene::Visibility_Check rewrites + // it for every render object once per VIEW per frame, from that view's camera frustum and that + // view's player's vision (W3DScene.cpp:534-655). Display::drawViews walks the view list head to + // tail (Display.cpp:163) and Display::attachView PREPENDS (Display.cpp:109), so seat 0's view - + // attached first - is drawn LAST, and seat 0's visibility set is the one standing by the time + // the message stream is translated. A pad seat's point pick is therefore answered against what + // SEAT 0 can see. Its own units, framed by its own camera somewhere seat 0 is not looking, are + // culled or shrouded away and the ray never tests them. The drag path is unaffected because + // iterateDrawablesInRegion's rect branch walks TheGameClient->firstDrawable() and never reads + // the flag - which is exactly the reported "drag selects, click does not". + // + // Bypassing the filter is not the fix (it would let a seat pick units hidden in its own fog), + // but it is the one-run test that settles whether this is the cause: with GX_PICKALL=1 a pad + // seat's click-select either starts working - diagnosis confirmed - or does not, and the cause + // is elsewhere entirely. + static const Bool s_pickAll = (getenv("GX_PICKALL") != nullptr); + + if( W3DDisplay::m_3DScene->castRay( raytest, s_pickAll, (Int)pickType ) ) renderObj = raytest.CollidedRenderObj; // for right now there is no drawable data in a render object which is // if we've found a render object, return our drawable associated with it, From c2eb2d73b714384ca351317d944d6c3cee6c815a Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:58:51 +0300 Subject: [PATCH 28/42] splitscreen: stop re-probing cursor states that have no art at all Follow-up to the seat cursor fix. cursorUsesNumberedArt left s_naming UNRESOLVED whenever neither the unnumbered nor the numbered texture measured, so the 27 retail cursor states that have no texture art in either convention - they exist only as Data\Cursors\*.ani and Art\W3D\*.W3D - re-measured two absent textures on every seat on every frame, forever. Adds an ABSENT state so a conclusive miss is latched. The wrinkle is that Get_Texture returns its placeholder both for "file does not exist" and for "archives not mounted yet", and those are indistinguishable at this level, so ABSENT is only latched after a bounded number of attempts - one second of frames, far more than a mounted archive needs. Behaviour is unchanged: ABSENT reports not-numbered, the size guard rejects the placeholder and drawSeatCursor falls back to the arrow, exactly as before. Co-Authored-By: Claude Opus 5 --- .../GameClient/W3DSeatCursorRenderer.cpp | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DSeatCursorRenderer.cpp b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DSeatCursorRenderer.cpp index 9d90781d1b9..91b9e068322 100644 --- a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DSeatCursorRenderer.cpp +++ b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DSeatCursorRenderer.cpp @@ -190,8 +190,20 @@ static Bool measureCursorTexture(const AsciiString &file, ICoord2D *sizeOut) // Resolved once per cursor state and cached: this runs per seat per frame. static Bool cursorUsesNumberedArt(const CursorInfo *info, Int cursorType) { - enum { UNRESOLVED = 0, UNNUMBERED, NUMBERED }; + // ABSENT means "this cursor state has no texture art in either naming convention" - 27 of the + // 37 states shipped by retail are in that position, existing only as Data\Cursors\*.ani and + // Art\W3D\*.W3D. They fall back to the arrow, as before. It is a distinct state from + // UNRESOLVED so we stop re-probing them: without it, every seat re-measured two absent + // textures every frame forever. + enum { UNRESOLVED = 0, UNNUMBERED, NUMBERED, ABSENT }; static Int s_naming[Mouse::NUM_MOUSE_CURSORS]; + static Int s_attempts[Mouse::NUM_MOUSE_CURSORS]; + + // A miss is only conclusive once the asset manager is actually serving files. Get_Texture + // hands back its placeholder for "absent" AND for "not mounted yet", and those are + // indistinguishable from here, so give the archives a bounded number of frames to show up + // before latching ABSENT. One second at 60fps is far more than a mounted archive needs. + static const Int MAX_RESOLVE_ATTEMPTS = 60; if (cursorType < 0 || cursorType >= Mouse::NUM_MOUSE_CURSORS) return FALSE; @@ -199,19 +211,19 @@ static Bool cursorUsesNumberedArt(const CursorInfo *info, Int cursorType) if (s_naming[cursorType] == UNRESOLVED) { ICoord2D size; - const AsciiString plain = cursorTextureFileName( info, 0, FALSE ); - if (measureCursorTexture( plain, &size ) && isPlausibleCursorSize( size.x, size.y )) + if (measureCursorTexture( cursorTextureFileName( info, 0, FALSE ), &size ) + && isPlausibleCursorSize( size.x, size.y )) { s_naming[cursorType] = UNNUMBERED; } - else + else if (measureCursorTexture( cursorTextureFileName( info, 0, TRUE ), &size ) + && isPlausibleCursorSize( size.x, size.y )) + { + s_naming[cursorType] = NUMBERED; + } + else if (++s_attempts[cursorType] >= MAX_RESOLVE_ATTEMPTS) { - const AsciiString numbered = cursorTextureFileName( info, 0, TRUE ); - if (measureCursorTexture( numbered, &size ) && isPlausibleCursorSize( size.x, size.y )) - s_naming[cursorType] = NUMBERED; - // Neither measured: leave UNRESOLVED so we retry. Cursor textures load on demand and - // may not be resident the first frames a seat cursor is drawn; latching a guess here - // would make the miss permanent. + s_naming[cursorType] = ABSENT; } } From 66c286d51ae08ba0ade30ed032713640e3903662 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:08:58 +0300 Subject: [PATCH 29/42] splitscreen: a pad seat's click was answered against seat 0's vision, not its own Fixes #8, and #10's remaining half with it. Confirmed by A/B on a real pad, not argued. W3DView::pickDrawable casts through RTS3DScene::castRay with testAll=false, which considers only render objects flagged Is_Really_Visible(). That flag is RENDER RESIDUE. Visibility_Check rewrites it for every object in the scene once per VIEW per frame, from that view's camera frustum and that view's player's vision. Display::drawViews walks the view list head to tail and Display::attachView PREPENDS, so seat 0's view - attached first at InGameUI.cpp:1442 - sits at the tail and is drawn LAST. Its answer is the one still standing when the message stream is translated next frame. So every seat's point-pick was answered with SEAT 0's vision. A pad seat's own units, framed by its own camera somewhere seat 0 was not looking, were frustum-culled or shrouded away and the ray never tested them. Drag-select kept working because iterateDrawablesInRegion's rect branch walks TheGameClient->firstDrawable() and never reads the flag - which is exactly why the bug presented as "drag selects, click does not", for seats > 0 only. It explains the cursor too, so #8 and #10 really were one defect - handoff4 was right about that and wrong about the mechanism. createCommandHint takes `draw` from the pick, so an empty pick leaves drawSelectable FALSE and the MSG_DO_MOVETO_HINT arm falls through to MOVETO. That is the move cursor appearing over your own units instead of SELECTING. Fix: evaluate the predicate live for the picking view instead of reading the leftover bit. objectVisibleToView is Visibility_Check's own per-object decision - force-visible, hidden, frustum cull, seat owner filter, effectively-hidden/shroud-obscured - in the same order, so the two cannot drift. castRay takes an optional view camera and player; passing them switches it from residue to live evaluation. Bypassing the filter (testAll=TRUE) is NOT the fix even though it made the symptom go away in the probe run: it would let a seat pick units hidden in its own fog. Single view is byte-identical: the call site only supplies a camera when getBoundSeatCount() > 1, so a one-seat game takes the original branch unchanged. The Generals tree gets the same signature so the shared Core W3DView has one spelling to call. It has no splitscreen and no owner filter, so a supplied camera there only swaps the residue for a live frustum test, and viewPlayerIndex is unused. Supersedes the GX_PICKALL probe from 0a17bb649, which has served its purpose and is removed. Co-Authored-By: Claude Opus 5 --- .../Source/W3DDevice/GameClient/W3DView.cpp | 45 ++++++++++----- .../Include/W3DDevice/GameClient/W3DScene.h | 9 ++- .../Source/W3DDevice/GameClient/W3DScene.cpp | 17 +++++- .../Include/W3DDevice/GameClient/W3DScene.h | 11 +++- .../Source/W3DDevice/GameClient/W3DScene.cpp | 56 ++++++++++++++++++- 5 files changed, 113 insertions(+), 25 deletions(-) diff --git a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp index 6dcb52a79a3..e037c2605bf 100644 --- a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp +++ b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp @@ -2595,27 +2595,42 @@ Drawable *W3DView::pickDrawable( const ICoord2D *screen, Bool forceAttack, PickT //Don't check against translucent or hidden objects RayCollisionTestClass raytest(lineseg,&result,COLL_TYPE_ALL,false,false); - // Splitscreen probe (#8), instrumentation only - default OFF, set GX_PICKALL=1 to enable. + // Splitscreen (#8/#10): answer the visibility question for THIS view, not for whichever view + // happened to render last. // // castRay's testAll=false makes the point pick consider only render objects flagged // Is_Really_Visible(). That flag is pure RENDER RESIDUE: RTS3DScene::Visibility_Check rewrites // it for every render object once per VIEW per frame, from that view's camera frustum and that - // view's player's vision (W3DScene.cpp:534-655). Display::drawViews walks the view list head to - // tail (Display.cpp:163) and Display::attachView PREPENDS (Display.cpp:109), so seat 0's view - - // attached first - is drawn LAST, and seat 0's visibility set is the one standing by the time - // the message stream is translated. A pad seat's point pick is therefore answered against what - // SEAT 0 can see. Its own units, framed by its own camera somewhere seat 0 is not looking, are - // culled or shrouded away and the ray never tests them. The drag path is unaffected because - // iterateDrawablesInRegion's rect branch walks TheGameClient->firstDrawable() and never reads - // the flag - which is exactly the reported "drag selects, click does not". + // view's player's vision. Display::drawViews walks the view list head to tail and + // Display::attachView PREPENDS, so seat 0's view - attached first - is drawn LAST, and seat 0's + // visibility set is the one standing by the time the message stream is translated. A pad seat's + // point pick was therefore answered against what SEAT 0 can see: its own units, framed by its + // own camera somewhere seat 0 is not looking, were culled or shrouded away and the ray never + // tested them. Drag-select was unaffected because iterateDrawablesInRegion's rect branch walks + // TheGameClient->firstDrawable() and never reads the flag - which is exactly why "drag selects, + // click does not" was the reported shape. // - // Bypassing the filter is not the fix (it would let a seat pick units hidden in its own fog), - // but it is the one-run test that settles whether this is the cause: with GX_PICKALL=1 a pad - // seat's click-select either starts working - diagnosis confirmed - or does not, and the cause - // is elsewhere entirely. - static const Bool s_pickAll = (getenv("GX_PICKALL") != nullptr); + // It also explains the cursor. createCommandHint takes `draw` from the pick, so an empty pick + // leaves drawSelectable FALSE and the MSG_DO_MOVETO_HINT arm falls through to MOVETO - the move + // cursor over your own units, instead of SELECTING. + // + // Confirmed by A/B on a pad: bypassing the filter entirely made click-select work. Bypassing is + // NOT the fix though - it would let a seat pick units hidden in its own fog - so evaluate the + // real predicate against this view's camera and player instead. + // + // Gated on seat count so a single-viewport game takes the byte-identical legacy path. + CameraClass *pickCamera = nullptr; + Int pickPlayerIndex = -1; + if (TheSeatManager != nullptr && TheSeatManager->getBoundSeatCount() > 1) + { + pickCamera = m_3DCamera; + // Match Visibility_Check's own resolution exactly: the view's render player when it has + // one, otherwise the local/observed player - which is what seat 0's view renders as. + const Int rp = getRenderPlayerIndex(); + pickPlayerIndex = (rp >= 0) ? rp : rts::getObservedOrLocalPlayerIndex_Safe(); + } - if( W3DDisplay::m_3DScene->castRay( raytest, s_pickAll, (Int)pickType ) ) + if( W3DDisplay::m_3DScene->castRay( raytest, false, (Int)pickType, pickCamera, pickPlayerIndex ) ) renderObj = raytest.CollidedRenderObj; // for right now there is no drawable data in a render object which is // if we've found a render object, return our drawable associated with it, diff --git a/Generals/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DScene.h b/Generals/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DScene.h index 1d1e684b11c..e185b6a8a58 100644 --- a/Generals/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DScene.h +++ b/Generals/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DScene.h @@ -64,8 +64,13 @@ class RTS3DScene : public SimpleSceneClass, public SubsystemInterface RTS3DScene(); ///< RTSScene constructor virtual ~RTS3DScene() override; ///< RTSScene destructor - /// ray picking against objects in scene - Bool castRay(RayCollisionTestClass & raytest, Bool testAll, Int collisionType); + /// ray picking against objects in scene. + /// + /// The trailing two parameters mirror the Zero Hour signature so the shared Core W3DView can + /// call one spelling. This tree has no splitscreen and therefore no per-seat owner filter, so + /// a supplied camera only replaces the Is_Really_Visible() residue with a live frustum test. + Bool castRay(RayCollisionTestClass & raytest, Bool testAll, Int collisionType, + CameraClass *viewCamera = nullptr, Int viewPlayerIndex = -1); /// customizable renderer for the RTS3DScene virtual void Customized_Render( RenderInfoClass &rinfo ) override; diff --git a/Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DScene.cpp b/Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DScene.cpp index 29d60da1d5c..b3533123066 100644 --- a/Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DScene.cpp +++ b/Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DScene.cpp @@ -304,7 +304,8 @@ void RTS3DScene::flagOccludedObjects(CameraClass * camera) CollisionType is used as a mask to ignore certain types of objects. */ //============================================================================= -Bool RTS3DScene::castRay(RayCollisionTestClass & raytest, Bool testAll, Int collisionType) +Bool RTS3DScene::castRay(RayCollisionTestClass & raytest, Bool testAll, Int collisionType, + CameraClass *viewCamera, Int viewPlayerIndex) { // this shouldn't be necessary here, and would be an undesirable performance hit. // if you ever add or modify code here, it MIGHT become necessary... so do so with caution. (srj) @@ -333,8 +334,18 @@ Bool RTS3DScene::castRay(RayCollisionTestClass & raytest, Bool testAll, Int coll RenderObjClass * robj = it.Peek_Obj(); it.Next(); - // only intersect if it was visible or if we must test all - if(robj->Get_Collision_Type() & collisionType && (testAll || robj->Is_Really_Visible())) + // only intersect if it was visible or if we must test all. + // + // A supplied view camera replaces the Is_Really_Visible() render residue with a live test. + // This tree is single-viewport, so viewPlayerIndex is unused here - there is no per-seat + // owner filter to apply, unlike the Zero Hour build. + (void)viewPlayerIndex; + const Bool visible = (viewCamera != nullptr) + ? (robj->Is_Force_Visible() + || (!robj->Is_Hidden() && !viewCamera->Cull_Sphere( robj->Get_Bounding_Sphere() ))) + : (testAll || robj->Is_Really_Visible()); + + if(robj->Get_Collision_Type() & collisionType && visible) { // Do a quick ray-sphere test (Graphics Gems I, p388) const SphereClass *sphere = &robj->Get_Bounding_Sphere(); diff --git a/GeneralsMD/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DScene.h b/GeneralsMD/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DScene.h index 0b0ec6d5241..dc058ec7d34 100644 --- a/GeneralsMD/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DScene.h +++ b/GeneralsMD/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DScene.h @@ -64,8 +64,15 @@ class RTS3DScene : public SimpleSceneClass, public SubsystemInterface RTS3DScene(); ///< RTSScene constructor virtual ~RTS3DScene() override; ///< RTSScene destructor - /// ray picking against objects in scene - Bool castRay(RayCollisionTestClass & raytest, Bool testAll, Int collisionType); + /// ray picking against objects in scene. + /// + /// Splitscreen: pass viewCamera (and that view's player) to have visibility evaluated LIVE for + /// that view instead of read from Is_Really_Visible(). That flag is render residue - it is + /// rewritten per view, per frame, and the last view drawn wins - so a pick performed outside a + /// render pass otherwise answers with whichever seat happened to render last. Omit both for the + /// legacy behaviour. + Bool castRay(RayCollisionTestClass & raytest, Bool testAll, Int collisionType, + CameraClass *viewCamera = nullptr, Int viewPlayerIndex = -1); /// customizable renderer for the RTS3DScene virtual void Customized_Render( RenderInfoClass &rinfo ) override; diff --git a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DScene.cpp b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DScene.cpp index a0eaf21d6c0..1a48ba400c5 100644 --- a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DScene.cpp +++ b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DScene.cpp @@ -318,6 +318,48 @@ void RTS3DScene::flagOccludedObjects(CameraClass * camera) } } +static Bool seatOwnerFilterHidesObject(DrawableInfo *drawInfo, Drawable *draw, Int viewPlayerIndex); + +//============================================================================= +// objectVisibleToView +//============================================================================= +/** Would this render object be visible in the given view, right now? + + This is Visibility_Check's per-object decision, evaluated on demand instead of read back out + of the shared IS_VISIBLE bit. It has to exist because that bit is RENDER RESIDUE: Visibility_Check + rewrites it for every object once per view per frame, and Display::drawViews walks the view list + head to tail while attachView prepends - so seat 0's view is drawn last and its answer is the one + standing by the time input is translated. Anything that asks Is_Really_Visible() outside a render + pass therefore gets SEAT 0's vision, whoever is actually asking. For picking that meant a pad seat + could not click its own units: they sit where seat 0's camera is not looking, so they were culled + or shrouded away and the ray never tested them, while drag-select - which never consults the flag - + kept working. + + Kept deliberately in the same order as Visibility_Check so the two cannot drift. */ +//============================================================================= +static Bool objectVisibleToView(RenderObjClass *robj, CameraClass *camera, Int viewPlayerIndex) +{ + if (robj->Is_Force_Visible()) + return TRUE; + + if (robj->Is_Hidden()) + return FALSE; + + if (camera->Cull_Sphere(robj->Get_Bounding_Sphere())) + return FALSE; + + DrawableInfo *drawInfo = (DrawableInfo *)robj->Get_User_Data(); + Drawable *draw = drawInfo ? drawInfo->m_drawable : nullptr; + + if (seatOwnerFilterHidesObject(drawInfo, draw, viewPlayerIndex)) + return FALSE; + + if (draw != nullptr && (draw->isDrawableEffectivelyHidden() || draw->getFullyObscuredByShroud())) + return FALSE; + + return TRUE; +} + //============================================================================= // RTS3DScene::castRay //============================================================================= @@ -327,7 +369,8 @@ void RTS3DScene::flagOccludedObjects(CameraClass * camera) CollisionType is used as a mask to ignore certain types of objects. */ //============================================================================= -Bool RTS3DScene::castRay(RayCollisionTestClass & raytest, Bool testAll, Int collisionType) +Bool RTS3DScene::castRay(RayCollisionTestClass & raytest, Bool testAll, Int collisionType, + CameraClass *viewCamera, Int viewPlayerIndex) { // this shouldn't be necessary here, and would be an undesirable performance hit. // if you ever add or modify code here, it MIGHT become necessary... so do so with caution. (srj) @@ -356,8 +399,15 @@ Bool RTS3DScene::castRay(RayCollisionTestClass & raytest, Bool testAll, Int coll RenderObjClass * robj = it.Peek_Obj(); it.Next(); - // only intersect if it was visible or if we must test all - if(robj->Get_Collision_Type() & collisionType && (testAll || robj->Is_Really_Visible())) + // only intersect if it was visible or if we must test all. + // + // With a view camera supplied the visibility question is answered LIVE for that view rather + // than read from the shared IS_VISIBLE bit, which belongs to whichever view rendered last. + const Bool visible = (viewCamera != nullptr) + ? objectVisibleToView( robj, viewCamera, viewPlayerIndex ) + : (testAll || robj->Is_Really_Visible()); + + if(robj->Get_Collision_Type() & collisionType && visible) { // Do a quick ray-sphere test (Graphics Gems I, p388) const SphereClass *sphere = &robj->Get_Bounding_Sphere(); From 478b07df85a5d07fbe62c2064b94a79e8941008c Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:29:49 +0300 Subject: [PATCH 30/42] splitscreen: only seat 0's building placement was ever updated, so no other seat saw a ghost Reported live: on a pad seat, click a structure in the control bar, click again to place, nothing happens - and the building preview never appears at all. The arm side was already correct. ControlBarCommandProcessing passes m_seatIndex, so placeBuildAvailable sets ctx.m_pendingPlaceType for the right seat, creates the preview drawable and tags it to that seat's player. Nothing was wrong there, which is why this survived the round that fixed #9's arm/consume pair. What was missing is the PER-FRAME UPDATE. handleBuildPlacements resolves everything through m_activeSeat, and it is called from InGameUI::update - not from message translation. m_activeSeat is only non-zero while a seat's message is being translated; InGameUI.h says so directly: "Render/HUD code always runs with m_activeSeat == 0". So the update serviced seat 0 and nothing else. A pad seat's ghost was created and then never moved to that seat's cursor, never legality-checked, never tinted - so it sat wherever it was born and the placement could not be completed. Two more seat-0 assumptions inside the same function would have kept it broken even once the right context was reached: * the cursor position came from TheMouse->getMouseStatus(), the OS pointer. A pad seat has none, so every seat's ghost would have tracked seat 0's mouse. * all five screenToTerrain calls went through TheTacticalView, seat 0's camera, so a seat's pixels projected to the wrong world position - a building placed somewhere other than where it was aimed. Fix: loop the seats that actually have a placement pending and scope m_activeSeat around the body, which is the same mechanism MessageStream already uses. That is what keeps the change small - every legacy accessor inside (isPlacementAnchored, getPlacementPoints, getPendingPlaceSourceObjectID, the m_seatContexts lookups) then answers for the right seat with no further edit. The cursor now comes from getSeatHoverPixel, which returns TheMouse for seat 0 unchanged, and the projections go through that seat's own View. Single view is unchanged: the loop finds seat 0 alone, setActiveSeat(0) is what it already was, getSeatHoverPixel(0) is TheMouse, and viewForSeat(0) is TheTacticalView. Co-Authored-By: Claude Opus 5 --- .../GameEngine/Include/GameClient/InGameUI.h | 1 + .../GameEngine/Source/GameClient/InGameUI.cpp | 78 ++++++++++++++++--- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h index af74ee45dfe..12469793230 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h @@ -803,6 +803,7 @@ friend class Drawable; // for selection/deselection transactions void destroyPlacementIcons( Int seat = 0 ); ///< Destroy placement icons for the given seat void handleBuildPlacements(); ///< handle updating of placement icons based on mouse pos + void handleBuildPlacementsForActiveSeat(); ///< as above, for m_activeSeat alone; the loop above scopes it void handleRadiusCursor(); ///< handle updating of "radius cursors" that follow the mouse pos void incrementSelectCount( Int seat = 0 ) { ++m_seatContexts[seat].m_selectCount; } ///< Increase by one the running total of "selected" drawables diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index 5205a392149..44285768b82 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -1721,7 +1721,61 @@ void InGameUI::evaluateSoloNexus( Drawable *newlyAddedDrawable, Int seat ) // lifetime lives. static void tagPlacementIconOwner( Drawable *draw, Int seat ); +// Splitscreen: the pixel a seat is hovering at, and the View it looks through. Both are defined +// further down / in MessageStream; declared here because the placement update needs them and runs +// long before either. +static Bool getSeatHoverPixel( Int seat, ICoord2D *out ); + +static View *viewForSeat( Int seat ) +{ +#if RTS_SDL3_ENABLE + if( seat > 0 && TheSeatManager != nullptr ) + { + LocalSeat *s = TheSeatManager->getSeat( seat ); + if( s != nullptr && s->m_view != nullptr ) + return s->m_view; + } +#endif + return TheTacticalView; +} + +//------------------------------------------------------------------------------------------------- +/** Splitscreen: every seat can have a building placement in flight at once, so service them all. + + This runs from the per-frame UI update, NOT from message translation - and m_activeSeat is only + non-zero while a seat's message is being translated (see setActiveSeat). So the body below, + which resolves everything through m_activeSeat, only ever serviced SEAT 0. A pad seat armed its + placement correctly and its ghost drawable was created and tagged, but nothing ever moved that + ghost to the seat's cursor or ran the legality check on it - so no preview appeared and the + placement could not be completed. "I click a building, click again to place, and I never even + see the building." + + Scoping m_activeSeat around the body is deliberately the same mechanism MessageStream uses, and + it is what makes the fix small: every legacy accessor inside (isPlacementAnchored, + getPlacementPoints, getPendingPlaceSourceObjectID, the m_seatContexts lookups) then answers for + the right seat with no further change. + + Single view is unchanged: the loop finds seat 0 only, and setActiveSeat(0) is what it already + was. */ +//------------------------------------------------------------------------------------------------- void InGameUI::handleBuildPlacements() +{ + const Int prevActiveSeat = m_activeSeat; + + for( Int seat = 0; seat < MAX_SEATS; ++seat ) + { + if( m_seatContexts[ seat ].m_pendingPlaceType == nullptr ) + continue; + + setActiveSeat( seat ); + handleBuildPlacementsForActiveSeat(); + } + + setActiveSeat( prevActiveSeat ); +} + +//------------------------------------------------------------------------------------------------- +void InGameUI::handleBuildPlacementsForActiveSeat() { // @@ -1734,6 +1788,10 @@ void InGameUI::handleBuildPlacements() Coord3D world; Real angle = m_seatContexts[m_activeSeat].m_placeIcon[ 0 ]->getOrientation(); + // Splitscreen: this seat's camera. Projecting a seat's pixels through seat 0's view puts + // the building somewhere else entirely in the world. + View *placeView = viewForSeat( m_activeSeat ); + // update the angle of the icon to match any placement angle and pick the // location the icon will be at (anchored is the start, otherwise it's the mouse) if( isPlacementAnchored() ) @@ -1752,8 +1810,8 @@ void InGameUI::handleBuildPlacements() Coord3D worldStart, worldEnd; // project the start and the end points of the line anchor into the 3D world - TheTacticalView->screenToTerrain( &start, &worldStart ); - TheTacticalView->screenToTerrain( &end, &worldEnd ); + placeView->screenToTerrain( &start, &worldStart ); + placeView->screenToTerrain( &end, &worldEnd ); Coord2D v; v.x = worldEnd.x - worldStart.x; @@ -1772,17 +1830,17 @@ void InGameUI::handleBuildPlacements() } else { - const MouseIO *mouseIO = TheMouse->getMouseStatus(); - - // location is the mouse position - loc = mouseIO->pos; - + // Splitscreen: THIS seat's cursor. A pad seat has no OS pointer at all, so reading + // TheMouse here anchored every seat's placement ghost to seat 0's mouse. Seat 0 still + // reads TheMouse - that is what getSeatHoverPixel does for seat 0. + if( !getSeatHoverPixel( m_activeSeat, &loc ) ) + return; } // set the location and angle of the place icon /**@todo this whole orientation vector thing is LAME! Must replace, all I want to to do is set a simple angle and have it automatically change, ug! */ - TheTacticalView->screenToTerrain( &loc, &world ); + placeView->screenToTerrain( &loc, &world ); m_seatContexts[m_activeSeat].m_placeIcon[ 0 ]->setPosition( &world ); m_seatContexts[m_activeSeat].m_placeIcon[ 0 ]->setOrientation( angle ); @@ -1848,8 +1906,8 @@ void InGameUI::handleBuildPlacements() // project the start and the end points of the line anchor into the 3D world Coord3D worldStart, worldEnd; - TheTacticalView->screenToTerrain( &screenStart, &worldStart ); - TheTacticalView->screenToTerrain( &screenEnd, &worldEnd ); + placeView->screenToTerrain( &screenStart, &worldStart ); + placeView->screenToTerrain( &screenEnd, &worldEnd ); // how big are each of our objects Real objectSize = m_seatContexts[m_activeSeat].m_pendingPlaceType->getTemplateGeometryInfo().getMajorRadius() * 2.0f; From 67801c8ca6477ac7ed0f41fb704782916b22eb96 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:35:00 +0300 Subject: [PATCH 31/42] splitscreen: the ghost draw gate and the ghost visibility filter disagreed Two places decide whether a fogged building's stand-in belongs in a viewport, and they said different things about the same object. seatOwnerFilterHidesObject - the one Visibility_Check consults - is deliberately lenient (W3DScene.cpp:524-532). The scene holds ONE snapshot per object, whichever seat fogged it last, but every seat that has fogged it recorded its own and they all depict the same building in the same place. So a viewport whose player also remembers the object is shown the stand-in; only a player with no memory of it at all is shown nothing. Its comment says exactly that. renderSingleDrawable then tested only "does the scene copy belong to me" and returned early otherwise, which puts back the hole that rule exists to avoid: a seat that had scouted a civilian building saw nothing there as soon as another local seat was the more recent one to fog it. The disagreement was pre-existing - Visibility_Check has always used the lenient form - but it was one-sided and mostly invisible. It became reachable from the other side in 66c286d51, which made the pick answer per-seat: a seat could then CLICK a ghost building its own viewport was refusing to draw. Fix: the draw gate now asks the same question as the filter. Single view is unaffected - with one seat there is only ever one snapshot owner, so the added term cannot change the answer. This is a coherence fix, not a claim to have closed #12. #12 has a second, independent sub-mechanism - snapShot displaces the real render object out of RenderList under an ownsScene guard that restoreParentObject has no counterpart for, so a seat meeting a displaced building for the first time has neither a snapshot nor a FOGGED->clear edge to trigger a restore, and no visibility predicate can reach an object that is not in the list at all. That one still needs a probe run to confirm it is the live case. Co-Authored-By: Claude Opus 5 --- .../Source/W3DDevice/GameClient/W3DScene.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DScene.cpp b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DScene.cpp index 1a48ba400c5..8fdad033f52 100644 --- a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DScene.cpp +++ b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DScene.cpp @@ -894,7 +894,17 @@ void RTS3DScene::renderOneObject(RenderInfoClass &rinfo, RenderObjClass *robj, I if (drawInfo->m_ghostObject != nullptr) { const Int ghostOwner = drawInfo->m_ghostObject->getSceneSnapshotPlayer(); - if (ghostOwner >= 0 && ghostOwner != localPlayerIndex) + // This has to say exactly what seatOwnerFilterHidesObject says, or the two disagree + // about the same object. That function is the one Visibility_Check uses, and it + // deliberately lets a viewport draw the stand-in when its OWN player also remembers + // the object: the scene holds only one snapshot - whichever seat fogged it last - + // while every seat that fogged it recorded its own, and they all depict the same + // building in the same place. Testing only "does the scene copy belong to me" put + // back the hole that rule exists to avoid. It also became visible from the other + // side once picking started answering per-seat, because a seat could then click a + // ghost building that its own viewport was refusing to draw. + if (ghostOwner >= 0 && ghostOwner != localPlayerIndex + && !drawInfo->m_ghostObject->hasSnapshotForPlayer(localPlayerIndex)) { if (probing) probeRecord(rinfo, robj, callPath, nullptr, ss, ghostOwner, "SKIP ghost belongs to another seat"); From 3b48007c0471265f3f172919dc1412f8e8aa5454 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:20:54 +0300 Subject: [PATCH 32/42] splitscreen: one seat's placement wiped every other seat's build footprint Regression from 478b07df8, reported live: with a placement armed on the pad seat, seat 0's placement square stopped drawing - "there is no more square that draws; I had to build or move to make it appear again". removeAllBibs() is GLOBAL - it clears every seat's footprint decal at once - and 478b07df8 left it inside the body that now runs once per seat. So on each odd frame seat 0's pass added its bib and seat 1's pass immediately removed it again, permanently, for as long as any other seat had a placement armed. Only the last seat in the loop kept a footprint. Hoisted the clear into the caller, on the same odd-frame cadence the per-seat legality check uses so the two stay in step. Each seat then adds its own bib after the single global clear. Single view is unchanged: with one seat the clear happens once per odd frame and one seat adds its bib, exactly as before. Co-Authored-By: Claude Opus 5 --- .../Code/GameEngine/Source/GameClient/InGameUI.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index 44285768b82..150e023f393 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -1760,6 +1760,14 @@ static View *viewForSeat( Int seat ) //------------------------------------------------------------------------------------------------- void InGameUI::handleBuildPlacements() { + // The bib pass is GLOBAL: removeAllBibs() clears every seat's footprint decal at once, so it + // must not sit inside the per-seat body. Running it there means seat 1's pass wipes the bib + // seat 0 added a moment earlier, and seat 0's placement square disappears for as long as any + // other seat has a placement armed. Clear once here, then let each seat add its own below. + // Same odd-frame cadence the per-seat legality check uses, so the two stay in step. + if( TheGameClient->getFrame() & 0x1 ) + TheTerrainVisual->removeAllBibs(); + const Int prevActiveSeat = m_activeSeat; for( Int seat = 0; seat < MAX_SEATS; ++seat ) @@ -1855,7 +1863,8 @@ void InGameUI::handleBuildPlacementsForActiveSeat() // if( TheGameClient->getFrame() & 0x1 ) { - TheTerrainVisual->removeAllBibs(); + // NOTE: removeAllBibs() lives in the caller - it is global and would wipe the other + // seats' bibs from here. See handleBuildPlacements(). Object *builderObject = TheGameLogic->findObjectByID( getPendingPlaceSourceObjectID() ); From a24f7b44e46da099e679d1a8b70c19048cbce2f9 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:56:25 +0300 Subject: [PATCH 33/42] splitscreen: the placement accessors all answered for seat 0, so a pad seat could never build Three reported symptoms, one cause. Operator, on a pad seat: the building ghost is red everywhere so it can never be placed; the same press that should place it also orders the dozer to walk there; and the ghost only appears while the button is held. Every no-arg overload in the placement accessor family forwards to a LITERAL 0 (InGameUI.cpp:3848, :3862, :3879, :3906, :3924, :3941, :3961) - not m_activeSeat, not the acting seat. Both the message translator and the per-frame ghost updater are seat-scoped and call them. RED EVERYWHERE. handleBuildPlacementsForActiveSeat read getPendingPlaceSourceObjectID() no-arg, so seat 1 got seat 0's builder - INVALID_ID whenever seat 0 has nothing armed. BuildAssistant.cpp:936 then leaves playerIndex at -1, and PartitionManager.cpp:3258 returns CELLSHROUD_SHROUDED for a negative player before it looks at a cell at all, so isLocationLegalToBuild returns LBC_SHROUD at every position on the map. InGameUI.cpp:1885 is the engine's only IllegalBuildColor tint, so the ghost is red by construction, everywhere, forever. The DEBUG_ASSERTCRASH guarding that case is compiled to (void)0 in this build. CLICK ALSO MOVES. PlaceEventTranslator reads through placeSeat everywhere (:90, :91, :103, :177, :190, :195, :338, :348) but four WRITES had no seat: :115, :154, :317, :356. So a pad press armed seat 0's anchor, seat 1's own isPlacementAnchored(placeSeat) at :177 stayed FALSE, the commit block was skipped, and disp stayed at its KEEP_MESSAGE initialiser - the click fell through to the command translator, which issued the move. Translator order is Place 30 before Command 70 (GameClient.cpp:294-303), so "placement runs too late" is refuted: it saw the click first and declined it. GHOST ONLY WHILE HELD. Same latch from the other side: once a pad press sets seat 0's anchor, isPlacementAnchored() reads TRUE for every seat, so the ghost position comes from seat 0's stale anchor pixel instead of the correct getSeatHoverPixel(m_activeSeat) branch. That is also why the stick did not move the ghost but the button did. Fixed by routing to the seat the code is already scoped to: six reads in handleBuildPlacementsForActiveSeat take m_activeSeat, four writes in PlaceEventTranslator take placeSeat. Three further no-arg calls in seat-scoped translators went with them, one of which is a real cross-seat bug in its own right: SelectionXlat.cpp:1044 tested seat 0's placement and :1046 called the 2-arg placeBuildAvailable, so a PAD seat's right-click cancelled PLAYER 1's building placement. Also SelectionXlat.cpp:983 and WindowXlat.cpp:262. Left alone deliberately: W3DInGameUI.cpp:687 reads isPlacementAnchored() from RENDER code, where m_activeSeat is always 0. It drives m_buildingPlacementAnchor/m_buildingPlacementArrow, which are single shared render objects - making the line-build drag arrow per-seat is a separate change, not a one-line seat argument. Single view is unchanged throughout: with one seat every argument added here is 0, which is exactly what the no-arg overloads were passing. Co-Authored-By: Claude Opus 5 --- .../Code/GameEngine/Source/GameClient/InGameUI.cpp | 12 ++++++------ .../MessageStream/PlaceEventTranslator.cpp | 8 ++++---- .../GameClient/MessageStream/SelectionXlat.cpp | 8 +++++--- .../Source/GameClient/MessageStream/WindowXlat.cpp | 2 +- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index 150e023f393..69e50140837 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -1802,12 +1802,12 @@ void InGameUI::handleBuildPlacementsForActiveSeat() // update the angle of the icon to match any placement angle and pick the // location the icon will be at (anchored is the start, otherwise it's the mouse) - if( isPlacementAnchored() ) + if( isPlacementAnchored( m_activeSeat ) ) { ICoord2D start, end; // get the placement arrow points - getPlacementPoints( &start, &end ); + getPlacementPoints( &start, &end, m_activeSeat ); // set icon to anchor point loc = start; @@ -1866,7 +1866,7 @@ void InGameUI::handleBuildPlacementsForActiveSeat() // NOTE: removeAllBibs() lives in the caller - it is global and would wipe the other // seats' bibs from here. See handleBuildPlacements(). - Object *builderObject = TheGameLogic->findObjectByID( getPendingPlaceSourceObjectID() ); + Object *builderObject = TheGameLogic->findObjectByID( getPendingPlaceSourceObjectID( m_activeSeat ) ); LegalBuildCode lbc; lbc = TheBuildAssistant->isLocationLegalToBuild( &world, @@ -1905,13 +1905,13 @@ void InGameUI::handleBuildPlacementsForActiveSeat() // similarly placed object ... for those we will have them be oriented the same way // as the first one, but we'll set their positions so that they "tile" end to end // - if( isPlacementAnchored() && TheBuildAssistant->isLineBuildTemplate( m_seatContexts[m_activeSeat].m_pendingPlaceType ) ) + if( isPlacementAnchored( m_activeSeat ) && TheBuildAssistant->isLineBuildTemplate( m_seatContexts[m_activeSeat].m_pendingPlaceType ) ) { Int i; // get our line placement points ICoord2D screenStart, screenEnd; - getPlacementPoints( &screenStart, &screenEnd ); + getPlacementPoints( &screenStart, &screenEnd, m_activeSeat ); // project the start and the end points of the line anchor into the 3D world Coord3D worldStart, worldEnd; @@ -1925,7 +1925,7 @@ void InGameUI::handleBuildPlacementsForActiveSeat() Int maxObjects = TheGlobalData->m_maxLineBuildObjects; // get the builder object that will be constructing things - Object *builderObject = TheGameLogic->findObjectByID( getPendingPlaceSourceObjectID() ); + Object *builderObject = TheGameLogic->findObjectByID( getPendingPlaceSourceObjectID( m_activeSeat ) ); // // given the start/end points in the world and the the angle of the wall, fill diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/PlaceEventTranslator.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/PlaceEventTranslator.cpp index 7c21ad17414..bd67f04ea18 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/PlaceEventTranslator.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/PlaceEventTranslator.cpp @@ -112,7 +112,7 @@ GameMessageDisposition PlaceEventTranslator::translateGameMessage(const GameMess } // set this location as the placement anchor - TheInGameUI->setPlacementStart( &mouse ); + TheInGameUI->setPlacementStart( &mouse, placeSeat ); /* // @@ -151,7 +151,7 @@ GameMessageDisposition PlaceEventTranslator::translateGameMessage(const GameMess { // start placement anchor - TheInGameUI->setPlacementStart(&mouse); + TheInGameUI->setPlacementStart(&mouse, placeSeat); } */ @@ -314,7 +314,7 @@ GameMessageDisposition PlaceEventTranslator::translateGameMessage(const GameMess TheAudio->addAudioEvent( &noCanDoSound ); // unhook the anchor so they can try again - TheInGameUI->setPlacementStart( nullptr ); + TheInGameUI->setPlacementStart( nullptr, placeSeat ); } @@ -353,7 +353,7 @@ GameMessageDisposition PlaceEventTranslator::translateGameMessage(const GameMess if( sqrt( (x * x) + (y * y) ) >= PLACEMENT_DRAG_THRESHOLD_DIST ) { - TheInGameUI->setPlacementEnd(&mouse); + TheInGameUI->setPlacementEnd(&mouse, placeSeat); disp = DESTROY_MESSAGE; } diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp index c86781224f3..b882078470c 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/SelectionXlat.cpp @@ -980,7 +980,7 @@ GameMessageDisposition SelectionTranslator::translateGameMessage(const GameMessa if( !TheInGameUI->getGUICommand() && !getCommandActingShift() && !TheKeyboard->isCtrl() && !TheKeyboard->isAlt() ) { //No GUI command mode, so deselect everyone if we're in alternate mouse mode. - if( TheGlobalData->m_useAlternateMouse && TheInGameUI->getPendingPlaceSourceObjectID() == INVALID_ID ) + if( TheGlobalData->m_useAlternateMouse && TheInGameUI->getPendingPlaceSourceObjectID( getCommandActingSeat() ) == INVALID_ID ) { if( !TheInGameUI->getPreventLeftClickDeselectionInAlternateMouseModeForOneClick() ) { @@ -1041,9 +1041,11 @@ GameMessageDisposition SelectionTranslator::translateGameMessage(const GameMessa { //In alternate mouse mode, right click still cancels building placement. // TheSuperHackers @tweak Stubbjax 08/08/2025 Canceling building placement no longer deselects the builder. - if (TheInGameUI->getPendingPlaceSourceObjectID() != INVALID_ID) + if (TheInGameUI->getPendingPlaceSourceObjectID( getCommandActingSeat() ) != INVALID_ID) { - TheInGameUI->placeBuildAvailable(nullptr, nullptr); + // Splitscreen: cancel THIS seat's placement. The 2-arg form forwards to seat 0, + // so a pad seat's right-click used to cancel player 1's building placement. + TheInGameUI->placeBuildAvailable(nullptr, nullptr, getCommandActingSeat()); TheInGameUI->setPreventLeftClickDeselectionInAlternateMouseModeForOneClick(FALSE); disp = DESTROY_MESSAGE; TheInGameUI->setScrolling(FALSE); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/WindowXlat.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/WindowXlat.cpp index d0818626d04..3fa50e2a89e 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/WindowXlat.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/WindowXlat.cpp @@ -259,7 +259,7 @@ GameMessageDisposition WindowTranslator::translateGameMessage(const GameMessage // ------------------------------------------------------------------------ case GameMessage::MSG_RAW_MOUSE_LEFT_BUTTON_UP: { - if( TheInGameUI && TheInGameUI->isPlacementAnchored() ) + if( TheInGameUI && TheInGameUI->isPlacementAnchored( getCommandActingSeat() ) ) { //If we release the button outside forceKeepMessage = TRUE; From cfd2eee20c7437a9c3bc6cdc219f9f41d6c07ecd Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:12:01 +0300 Subject: [PATCH 34/42] probe: report every seat's cursor, not just the first one drawn The on-screen SEATCURSOR line latched to the first seat drawn each frame, which is always seat 0, so it could not answer the only question now outstanding about cursors: what shape is a PAD seat asking for, and what art does that resolve to. The latch was itself a fix for the opposite failure - the LAST seat used to overwrite the line, so a perfectly healthy "seat7 SCCPointer.tga 32x32" stood in for seat 0's broken cursor. One line per seat answers both without either hiding the other, and mirrors the control bar report that already works this way. This matters before any work on the missing cursor art. 27 of the 37 cursor states ship no texture at all - only Data\Cursors\*.ani and Art\W3D\*.W3D - and making them renderable is roughly a day. That is only worth spending if the hint logic actually selects those states for a pad seat. If a pad seat's cursorType never leaves ARROW, the art is not the bug and decoding it buys nothing. This probe is what tells the two apart, and it costs nothing to run. Co-Authored-By: Claude Opus 5 --- .../Include/Common/RenderLeakProbe.h | 3 +- .../Source/Common/RenderLeakProbe.cpp | 38 +++++++++++++------ Core/GameEngine/Source/Common/SeatManager.cpp | 7 +++- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/Core/GameEngine/Include/Common/RenderLeakProbe.h b/Core/GameEngine/Include/Common/RenderLeakProbe.h index 59237304888..03903252c78 100644 --- a/Core/GameEngine/Include/Common/RenderLeakProbe.h +++ b/Core/GameEngine/Include/Common/RenderLeakProbe.h @@ -118,7 +118,8 @@ Int getShadowsSkipped(Int viewIndex); Int getVolumeShadowsDrawn(Int viewIndex); Int getVolumeShadowsSkipped(Int viewIndex); Int getShadowPassRan(Int viewIndex); ///< bit 0 = decal pass ran, bit 1 = stencil pass ran -const char* getSeatCursorReport(); +Int getSeatCursorReportCount(); +const char* getSeatCursorReport(Int i); Int getControlBarReportCount(); const char* getControlBarReport(Int index); diff --git a/Core/GameEngine/Source/Common/RenderLeakProbe.cpp b/Core/GameEngine/Source/Common/RenderLeakProbe.cpp index 8b42da9c07c..7b14487e83e 100644 --- a/Core/GameEngine/Source/Common/RenderLeakProbe.cpp +++ b/Core/GameEngine/Source/Common/RenderLeakProbe.cpp @@ -84,11 +84,20 @@ static Int s_pubVolShadowsSkipped[PROBE_MAX_VIEWS] = { 0 }; static Int s_shadowPassRan[PROBE_MAX_VIEWS] = { 0 }; static Int s_pubShadowPassRan[PROBE_MAX_VIEWS] = { 0 }; -static char s_seatCursorReport[128] = "(no seat cursor drawn)"; -// Only the first seat drawn each frame is reported, i.e. the lowest-numbered one. The renderer -// draws every visible seat, and without this the last seat overwrote the report - which is how a -// perfectly healthy "seat7 SCCPointer.tga 32x32" came to stand in for seat 0's broken cursor. -static Bool s_seatCursorReportedThisFrame = FALSE; +// One line per seat cursor drawn, rebuilt each frame - same shape as the control bar report +// below. +// +// This used to be a single line latched to the FIRST seat drawn, which is always seat 0. That was +// a deliberate fix for the opposite problem (the LAST seat overwriting it, so a healthy +// "seat7 SCCPointer.tga" stood in for seat 0's broken cursor) but it made the probe structurally +// unable to answer the question that matters now: what cursor is a PAD seat asking for, and what +// art does it resolve to. Keeping one line per seat answers both without either seat hiding the +// other. +enum { MAX_CURSOR_REPORTS = 8, CURSOR_REPORT_CHARS = 128 }; +static char s_seatCursorReport[MAX_CURSOR_REPORTS][CURSOR_REPORT_CHARS]; +static Int s_seatCursorReportCount = 0; +static Int s_pubSeatCursorReportCount = 0; +static char s_pubSeatCursorReport[MAX_CURSOR_REPORTS][CURSOR_REPORT_CHARS]; // One line per live control bar, rebuilt each frame (filled by noteControlBar below). enum { MAX_BAR_REPORTS = 8, BAR_REPORT_CHARS = 128 }; @@ -147,8 +156,12 @@ void beginFrame() strncpy(s_pubBarReport[b], s_barReport[b], BAR_REPORT_CHARS); s_barReportCount = 0; + s_pubSeatCursorReportCount = s_seatCursorReportCount; + for (Int c = 0; c < s_seatCursorReportCount; ++c) + strncpy(s_pubSeatCursorReport[c], s_seatCursorReport[c], CURSOR_REPORT_CHARS); + s_seatCursorReportCount = 0; + // start a new frame, targeted at wherever the mouse is pointing - s_seatCursorReportedThisFrame = FALSE; s_rowCount = 0; s_viewCount = 0; s_considered = 0; @@ -326,14 +339,14 @@ void noteSeatCursor(Int seatIndex, Int cursorType, const char* imageName, Int wi { if (!isEnabled()) return; - if (s_seatCursorReportedThisFrame) + if (s_seatCursorReportCount >= MAX_CURSOR_REPORTS) return; - s_seatCursorReportedThisFrame = TRUE; - snprintf(s_seatCursorReport, sizeof(s_seatCursorReport), + snprintf(s_seatCursorReport[s_seatCursorReportCount], CURSOR_REPORT_CHARS, "seat%d type=%d img=%s %dx%d drew=%d", seatIndex, cursorType, imageName != nullptr ? imageName : "(none)", width, height, (Int)drew); - s_seatCursorReport[sizeof(s_seatCursorReport) - 1] = 0; + s_seatCursorReport[s_seatCursorReportCount][CURSOR_REPORT_CHARS - 1] = 0; + ++s_seatCursorReportCount; } Int getShadowsDrawn(Int viewIndex) @@ -361,9 +374,10 @@ Int getShadowPassRan(Int viewIndex) return (viewIndex >= 0 && viewIndex < PROBE_MAX_VIEWS) ? s_pubShadowPassRan[viewIndex] : 0; } -const char* getSeatCursorReport() +Int getSeatCursorReportCount() { return s_pubSeatCursorReportCount; } +const char* getSeatCursorReport(Int i) { - return s_seatCursorReport; + return (i >= 0 && i < s_pubSeatCursorReportCount) ? s_pubSeatCursorReport[i] : ""; } void noteControlBar(Int seatIndex, Int playerIndex, Int rootCount, Real dockScale, diff --git a/Core/GameEngine/Source/Common/SeatManager.cpp b/Core/GameEngine/Source/Common/SeatManager.cpp index c2957092a8f..09a0ecab13d 100644 --- a/Core/GameEngine/Source/Common/SeatManager.cpp +++ b/Core/GameEngine/Source/Common/SeatManager.cpp @@ -220,7 +220,12 @@ static void SeatDebugDisplay(DebugDisplayInterface* dd, void* /*userData*/, FILE dd->printf("\n SHADOWpass "); for (Int v = 0; v < RenderLeakProbe::getViewCount() && v < 8; ++v) dd->printf(" v%d=%d", v, RenderLeakProbe::getShadowPassRan(v)); - dd->printf("\n SEATCURSOR %s\n", RenderLeakProbe::getSeatCursorReport()); + const Int cursorReports = RenderLeakProbe::getSeatCursorReportCount(); + if (cursorReports == 0) + dd->printf("\n SEATCURSOR (none drawn)\n"); + for (Int c = 0; c < cursorReports; ++c) + dd->printf("\n SEATCURSOR %s%s", RenderLeakProbe::getSeatCursorReport(c), + (c == cursorReports - 1) ? "\n" : ""); for (Int b = 0; b < RenderLeakProbe::getControlBarReportCount(); ++b) dd->printf(" %s\n", RenderLeakProbe::getControlBarReport(b)); } From 4b117700a32202c05917107e896f3348e6c281ea Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:25:38 +0300 Subject: [PATCH 35/42] splitscreen: seat cursors draw the .ani art for the 27 states that ship no texture The operator, comparing against stock Generals: "there is no proper icons over buildings, it should be this, and over units too waypoint, and over supplies/garrison the three green animated arrow should show ... you can not tell if something works unless you see it works." Retail ships texture art for only 8 of the 37 cursor states - SCCPointer, SCCAttack and SCCRepair unnumbered, plus SCCMove and SCCScroll as numbered frames. The other 27 (Select, EnterFriendly, Waypoint, Dock, SetRallyPoint, ResumeConstruction, CaptureBuilding, ...) exist ONLY as Data\Cursors\*.ani and Art\W3D\*.W3D. Seat 0 is unaffected: it runs RM_WINDOWS and hands the .ani straight to the window manager. A seat cursor is drawn by us through TheDisplay->drawImage, had nothing to draw, and silently fell back to the arrow - so a pad seat showed the same shape for garrison, waypoint, dock, select and invalid alike. That is also why it was hard to confirm any other fix: the cursor is the feedback channel. This is not a new decoder. SDL3CursorManager::initResources already loads every one of those .ani files at startup and IMG_LoadAnimation_IO already decodes them to RGBA; loadANI then threw the surfaces away at IMG_FreeAnimation the moment SDL had built an opaque SDL_Cursor from them. AnimatedCursor now retains a tightly-packed ARGB8888 copy plus the hotspot, and the seat renderer uploads one frame to a TextureClass on first use, wrapping it as an IMAGE_STATUS_RAW_TEXTURE Image the same way W3DRadar builds its per-player radar images. Nothing is read from or written to disk beyond what the engine already loads - the art is the player's own installed game data, decoded in memory - so no retail art is redistributed and the repo stays publishable. Order matters and is deliberate: mapped image, then texture, then .ani, then the arrow. Preferring .ani would take MOVE and SCROLL off their 32x32 DXT art and onto 4bpp indexed frames, undoing 6afadc1e2. Animation is driven by the .ani's OWN frame count, not CursorInfo::numFrames. Retail Mouse.ini declares no frame count for any cursor - unbounded grep, zero hits - so numFrames is always 1 and using it here would freeze every animated cursor on frame 0, including the three green arrows the operator specifically asked for. Clamped to MAX_2D_CURSOR_ANIM_FRAMES. Single view is unaffected: seat 0 draws its cursor through the OS, not through this renderer. Not yet runtime-verified. Co-Authored-By: Claude Opus 5 --- .../SDL3Device/GameClient/SDL3Cursor.h | 37 ++++- .../SDL3Device/GameClient/SDL3Cursor.cpp | 46 ++++++ .../GameClient/W3DSeatCursorRenderer.cpp | 146 +++++++++++++++++- 3 files changed, 226 insertions(+), 3 deletions(-) diff --git a/Core/GameEngineDevice/Include/SDL3Device/GameClient/SDL3Cursor.h b/Core/GameEngineDevice/Include/SDL3Device/GameClient/SDL3Cursor.h index e6be47868b4..9fce6a93fea 100644 --- a/Core/GameEngineDevice/Include/SDL3Device/GameClient/SDL3Cursor.h +++ b/Core/GameEngineDevice/Include/SDL3Device/GameClient/SDL3Cursor.h @@ -21,16 +21,41 @@ #include "Lib/BaseType.h" #include +#include #include #include "GameClient/Mouse.h" +// Splitscreen: one decoded frame of a cursor, kept in memory as tightly-packed ARGB8888. +// +// Seat cursors are drawn by us, not by the OS, so an SDL_Cursor is no use to them - it is opaque +// and only the window manager can draw it. Of the 37 cursor states, 27 ship NO texture art at all +// (only Data\Cursors\*.ani and Art\W3D\*.W3D), so a seat cursor had nothing to draw for any of +// them and silently fell back to the arrow - which is why a pad seat could not tell garrison from +// move from waypoint. The .ani pixels are the only redistributable-free source we have: they are +// the player's own installed game data, decoded at runtime and never written back to disk. +struct CursorFrameRGBA +{ + Int m_width; + Int m_height; + std::vector m_pixels; ///< w*h*4, ARGB8888, tightly packed + + CursorFrameRGBA() : m_width(0), m_height(0) {} +}; + struct AnimatedCursor { SDL_Cursor* m_cursor; + // Retained copy of what IMG_LoadAnimation_IO decoded. It used to be freed immediately after + // SDL_CreateColorCursor took it; keeping it costs a few KB per cursor state and is what lets a + // seat cursor draw real art instead of falling back to the arrow. + std::vector m_frames; + Int m_hotSpotX; + Int m_hotSpotY; + AnimatedCursor() - : m_cursor(nullptr) + : m_cursor(nullptr), m_hotSpotX(0), m_hotSpotY(0) {} ~AnimatedCursor() { @@ -42,6 +67,11 @@ struct AnimatedCursor } SDL_Cursor* getCursor() const { return m_cursor; } + Int getFrameCount() const { return (Int)m_frames.size(); } + const CursorFrameRGBA* getFrame(Int i) const + { + return (i >= 0 && i < (Int)m_frames.size()) ? &m_frames[i] : nullptr; + } }; class SDL3CursorManager @@ -52,6 +82,11 @@ class SDL3CursorManager static SDL_Cursor* getCursor(Mouse::MouseCursor cursor, int direction); + // Splitscreen: the decoded frames behind that cursor, for callers that must draw it + // themselves rather than hand it to the window manager. Null if the .ani was absent or + // failed to decode. + static const AnimatedCursor* getAnimatedCursor(Mouse::MouseCursor cursor, int direction); + // Internal loader used by Mouse implementation static void initResources(Mouse* mouse); diff --git a/Core/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Cursor.cpp b/Core/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Cursor.cpp index b806d55a5ed..8973a0a94b8 100644 --- a/Core/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Cursor.cpp +++ b/Core/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Cursor.cpp @@ -26,6 +26,8 @@ #include "Common/FileSystem.h" #include "SDL3Device/GameClient/SDL3Cursor.h" +#include // memcpy, for the retained cursor frames + AnimatedCursor* SDL3CursorManager::m_cursorResources[Mouse::NUM_MOUSE_CURSORS][MAX_2D_CURSOR_DIRECTIONS] = {nullptr}; void SDL3CursorManager::init() @@ -59,6 +61,17 @@ SDL_Cursor* SDL3CursorManager::getCursor(Mouse::MouseCursor cursor, int directio return anim ? anim->getCursor() : nullptr; } +const AnimatedCursor* SDL3CursorManager::getAnimatedCursor(Mouse::MouseCursor cursor, int direction) +{ + if (cursor < Mouse::FIRST_CURSOR || cursor >= Mouse::NUM_MOUSE_CURSORS) + return nullptr; + + if (direction < 0 || direction >= MAX_2D_CURSOR_DIRECTIONS) + direction = 0; + + return m_cursorResources[cursor][direction]; +} + void SDL3CursorManager::initResources(Mouse* mouse) { if (!mouse) @@ -144,6 +157,39 @@ AnimatedCursor* SDL3CursorManager::loadANI(const char* filepath) DEBUG_LOG(("loadANI: Failed to create cursor from %s. hot=(%d, %d), count=%d. Error: %s", filepath, hot_spot_x, hot_spot_y, anim->count, SDL_GetError())); } + // Splitscreen: keep the decoded pixels. SDL_Cursor is opaque and only the window manager can + // draw it, so seat cursors - which we draw ourselves - had no art for the 27 cursor states that + // ship no texture, and fell back to the arrow. Copy to tightly-packed ARGB8888 while the + // surfaces are still alive; IMG_FreeAnimation below releases them. + cursor->m_hotSpotX = hot_spot_x; + cursor->m_hotSpotY = hot_spot_y; + cursor->m_frames.resize(anim->count); + for (int i = 0; i < anim->count; ++i) + { + SDL_Surface *src = anim->frames[i]; + if (src == nullptr) + continue; + + // Convert rather than assume: .ani frames are commonly 4bpp or 8bpp indexed. + SDL_Surface *conv = SDL_ConvertSurface(src, SDL_PIXELFORMAT_ARGB8888); + if (conv == nullptr) + continue; + + CursorFrameRGBA &f = cursor->m_frames[i]; + f.m_width = conv->w; + f.m_height = conv->h; + f.m_pixels.resize((size_t)conv->w * (size_t)conv->h * 4u); + + // Copy row by row: the surface pitch is not necessarily w*4. + const UnsignedByte *srcBits = (const UnsignedByte *)conv->pixels; + for (int y = 0; y < conv->h; ++y) + memcpy(&f.m_pixels[(size_t)y * (size_t)conv->w * 4u], + srcBits + (size_t)y * (size_t)conv->pitch, + (size_t)conv->w * 4u); + + SDL_DestroySurface(conv); + } + IMG_FreeAnimation(anim); return cursor.release(); } diff --git a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DSeatCursorRenderer.cpp b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DSeatCursorRenderer.cpp index 91b9e068322..80af1695422 100644 --- a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DSeatCursorRenderer.cpp +++ b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DSeatCursorRenderer.cpp @@ -38,6 +38,14 @@ #include "W3DDevice/GameClient/W3DMouse.h" // MAX_2D_CURSOR_ANIM_FRAMES #include "WW3D2/assetmgr.h" #include "WW3D2/texture.h" +#include "WW3D2/surfaceclass.h" +#include "WW3D2/ww3dformat.h" + +#if RTS_SDL3_ENABLE +#include "SDL3Device/GameClient/SDL3Cursor.h" // the decoded .ani frames +#endif + +#include // memcpy, uploading a cursor frame // Fallback seat colors used until a seat is assigned a game player (then the // player's house color wins). Eight visually distinct, opaque colors; index 0 is @@ -332,6 +340,129 @@ static const Image *findCursorImage(Int cursorType, Int frame, const CursorInfo // device code. static Int s_cursorAnimTick = 0; +#if RTS_SDL3_ENABLE +//------------------------------------------------------------------------------------------------- +/** Splitscreen: draw a cursor state from its .ani, for the 27 states that ship no texture at all. + + Retail ships texture art for only 8 of the 37 cursor states - SCCPointer, SCCAttack and + SCCRepair, plus SCCMove and SCCScroll as numbered frames. Everything else (Select, EnterFriendly, + Waypoint, Dock, SetRallyPoint, ...) exists ONLY as Data\Cursors\*.ani and Art\W3D\*.W3D. Seat 0 + is unaffected because it runs RM_WINDOWS and hands the .ani straight to the window manager, but a + seat cursor is drawn by us and had nothing to draw, so it fell back to the arrow - which is why a + pad seat could not tell garrison from move from waypoint. + + The engine already decodes those .ani files at startup (SDL3CursorManager::initResources) and + used to throw the pixels away immediately; AnimatedCursor now retains them. This uploads one + frame to a texture on first use and caches the wrapper. Nothing is read from or written to disk: + the art is the player's own installed game data, decoded in memory. + + Animation is driven by the .ani's OWN frame count, not by CursorInfo::numFrames - retail Mouse.ini + declares no frame count for any cursor, so numFrames is always 1 and using it here would freeze + every animated cursor on frame 0. */ +//------------------------------------------------------------------------------------------------- +static const Image *findAniCursorImage(Int cursorType, const CursorInfo **infoOut) +{ + static Image *s_aniCache[Mouse::NUM_MOUSE_CURSORS][MAX_2D_CURSOR_ANIM_FRAMES]; + + if (TheMouse == nullptr) + return nullptr; + if (cursorType < Mouse::FIRST_CURSOR || cursorType >= Mouse::NUM_MOUSE_CURSORS) + return nullptr; + + const CursorInfo *info = TheMouse->getCursorInfo( cursorType ); + if (info == nullptr) + return nullptr; + + const AnimatedCursor *anim = + SDL3CursorManager::getAnimatedCursor( (Mouse::MouseCursor)cursorType, 0 ); + if (anim == nullptr) + return nullptr; + + Int count = anim->getFrameCount(); + if (count <= 0) + return nullptr; + if (count > MAX_2D_CURSOR_ANIM_FRAMES) + count = MAX_2D_CURSOR_ANIM_FRAMES; + + static const Int RENDER_FRAMES_PER_CURSOR_FRAME = 4; + const Int frame = (count > 1) + ? ((s_cursorAnimTick / RENDER_FRAMES_PER_CURSOR_FRAME) % count) + : 0; + + if (s_aniCache[cursorType][frame] == nullptr) + { + const CursorFrameRGBA *f = anim->getFrame( frame ); + if (f == nullptr || f->m_pixels.empty()) + return nullptr; + + // Same guard the texture path uses: refuse anything that is not cursor-shaped rather than + // stretching it across the battlefield. + if (!isPlausibleCursorSize( f->m_width, f->m_height )) + return nullptr; + + TextureClass *tex = MSGNEW("TextureClass") TextureClass( f->m_width, f->m_height, + WW3D_FORMAT_A8R8G8B8, MIP_LEVELS_1 ); + if (tex == nullptr) + return nullptr; + + SurfaceClass *surface = tex->Get_Surface_Level(); + if (surface == nullptr) + { + REF_PTR_RELEASE( tex ); + return nullptr; + } + + Bool uploaded = FALSE; + Int pitch = 0; + void *bits = surface->Lock( &pitch ); + if (bits != nullptr) + { + // Row by row: the locked pitch is not necessarily width * 4. + const UnsignedInt bpp = surface->Get_Bytes_Per_Pixel(); + const size_t rowBytes = (size_t)f->m_width * (size_t)bpp; + for (Int y = 0; y < f->m_height; ++y) + memcpy( (UnsignedByte *)bits + (size_t)y * (size_t)pitch, + &f->m_pixels[(size_t)y * (size_t)f->m_width * 4u], + rowBytes ); + surface->Unlock(); + uploaded = TRUE; + } + surface->Release_Ref(); + + if (!uploaded) + { + REF_PTR_RELEASE( tex ); + return nullptr; + } + + Region2D uv; + uv.lo.x = 0.0f; uv.lo.y = 0.0f; + uv.hi.x = 1.0f; uv.hi.y = 1.0f; + + ICoord2D size; + size.x = f->m_width; + size.y = f->m_height; + + AsciiString name; + name.format( "%s.ani[%d]", info->textureName.str(), frame ); + + Image *image = newInstance(Image); + image->setName( name ); + image->setStatus( IMAGE_STATUS_RAW_TEXTURE ); + image->setRawTextureData( tex ); + image->setUV( &uv ); + image->setTextureWidth( f->m_width ); + image->setTextureHeight( f->m_height ); + image->setImageSize( &size ); + + s_aniCache[cursorType][frame] = image; + } + + *infoOut = info; + return s_aniCache[cursorType][frame]; +} +#endif // RTS_SDL3_ENABLE + // Which animation frame a cursor should be showing right now. Animated cursors (the scroll and // attack pointers) otherwise sit frozen on frame 0. static Int currentCursorFrame(const CursorInfo *info) @@ -354,10 +485,21 @@ static void drawSeatCursor(const LocalSeat* seat) const CursorInfo *probe = TheMouse ? TheMouse->getCursorInfo( seat->m_cursor.cursorType ) : nullptr; const Image *image = findCursorImage( seat->m_cursor.cursorType, currentCursorFrame( probe ), &info ); + +#if RTS_SDL3_ENABLE + if (image == nullptr) + { + // No texture art for this state - true of 27 of the 37. Draw the .ani the OS cursor uses, + // which the engine already decoded at startup. This is what lets a seat cursor show + // garrison, waypoint, dock and the rest instead of an arrow for all of them. + image = findAniCursorImage( seat->m_cursor.cursorType, &info ); + } +#endif + if (image == nullptr) { - // This cursor state has no art defined - show the game's DEFAULT cursor rather than a - // stand-in shape, so a seat always displays real cursors like player 1 does. + // Nothing at all for this state - show the game's DEFAULT cursor rather than a stand-in + // shape, so a seat always displays real cursors like player 1 does. probe = TheMouse ? TheMouse->getCursorInfo( Mouse::ARROW ) : nullptr; image = findCursorImage( Mouse::ARROW, currentCursorFrame( probe ), &info ); } From 4e175bab4f4925008c6e24fcc41836b260882e3c Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:36:37 +0300 Subject: [PATCH 36/42] splitscreen: a seat could not see a building another seat had fogged, ever Finding #12, deferred since 2026-08-01 and confirmed still live today: a bunker visible in one seat's viewport and absent from the other's, standing right next to it. Same with oil derricks. The scene displacement is one-sided. W3DGhostObject::snapShot takes the REAL render object out of the shared scene - robj->Remove() at :463 - under an ownsScene guard meaning "I am the last local seat to lose sight of this". That part is right: the scene is shared, so one seat fogging a building must not blank it while another seat is looking at it. But every path that puts it back runs through freeSnapShot, and freeSnapShot needs two things that a seat meeting the object for the first time does not have. Its whole body is inside `if (m_parentSnapshots[playerIndex])`, and both callers in PartitionManager gate on `m_shroudednessPrevious[playerIndex] == OBJECTSHROUD_FOGGED`. A seat that has never seen the building has no snapshot, and goes straight from SHROUDED to CLEAR without ever being FOGGED. So neither gate opens, restoreParentObject never runs, and the real object stays out of RenderList - invisible to that seat permanently, at any range. Neutral IMMOBILE structures are the visible case because they are exactly what gets ghosted: PartitionData::getShroudedStatus forces anything neutral and mobile down to SHROUDED instead. Adds GhostObject::restoreIfDisplacedFor(playerIndex) - a no-op on the base, overridden in W3DGhostObject - called from the CLEAR and PARTIAL_CLEAR branches when the FOGGED precondition does NOT hold. It restores only when something is actually displacing the object (m_sceneSnapshotPlayer >= 0) and only for a local seat, then mirrors the restore already inside freeSnapShot: clear every seat's snapshot out of the scene, forget the owner, put the real object back. This is the second and last of the two mechanisms behind #12. The first was the draw gate disagreeing with the visibility filter, fixed in 67801c8ca; that one could not reach this case, because no visibility predicate can help an object that is not in the render list at all. Sim-safe: this only moves render objects in and out of the shared W3D scene. It reads shroud state that PartitionManager has already computed and writes none of it, and GhostObject's xfer chain is untouched. Single view is unchanged: with one seat, ownsScene is true exactly when that seat fogs the object and freeSnapShot's own FOGGED->clear edge always fires on the way back, so the new branch finds m_sceneSnapshotPlayer < 0 and returns immediately. Co-Authored-By: Claude Opus 5 --- .../Include/GameLogic/GhostObject.h | 13 ++++++++++++ .../GameLogic/Object/PartitionManager.cpp | 16 ++++++++++++++ .../W3DDevice/GameLogic/W3DGhostObject.h | 2 ++ .../W3DDevice/GameLogic/W3DGhostObject.cpp | 21 +++++++++++++++++++ 4 files changed, 52 insertions(+) diff --git a/GeneralsMD/Code/GameEngine/Include/GameLogic/GhostObject.h b/GeneralsMD/Code/GameEngine/Include/GameLogic/GhostObject.h index b956e12fd11..9ba0e48e7f9 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameLogic/GhostObject.h +++ b/GeneralsMD/Code/GameEngine/Include/GameLogic/GhostObject.h @@ -59,6 +59,19 @@ class GhostObject : public Snapshot own player has ever seen the thing. Without this a seat that has never laid eyes on a building still saw another seat's memory of it. */ virtual Bool hasSnapshotForPlayer(Int playerIndex) const { return TRUE; } + + /** Splitscreen: a local seat can now SEE this object with its own eyes. If the REAL render + object is currently displaced out of the shared scene by some seat's fogged snapshot, put it + back. + + This exists because the displacement is one-sided. snapShot() removes the real object from + the scene under an "am I the last local seat to lose sight" guard, but every restore path is + reached only through freeSnapShot(), which needs the seat to HAVE a snapshot and needs its + previous shroud state to have been FOGGED. A seat meeting a ghosted building for the first + time satisfies neither - it went straight from SHROUDED to CLEAR and never fogged anything - + so nothing ever brought the object back and it stayed invisible to that seat, however close + it stood. Neutral immobile structures (bunkers, oil derricks) are the visible case. */ + virtual void restoreIfDisplacedFor(int playerIndex) {} PartitionData *friend_getPartitionData() const {return m_partitionData;} GeometryType getGeometryType() const {return m_parentGeometryType;} Bool getGeometrySmall() const {return m_parentGeometryIsSmall;} diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/PartitionManager.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/PartitionManager.cpp index ef734640749..7f7bb02d4ee 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/PartitionManager.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/PartitionManager.cpp @@ -1706,6 +1706,14 @@ ObjectShroudStatus PartitionData::getShroudedStatus(Int playerIndex) //need a ghost object. m_ghostObject->freeSnapShot(playerIndex); } + else if (m_ghostObject) + { + // Splitscreen: this seat can see it now but never fogged it, so the branch above + // cannot fire - and if ANOTHER local seat's snapshot is currently displacing the + // real object out of the shared scene, this seat sees nothing there at all. That is + // the "bunker/oil derrick visible in one viewport but not the other" case. + m_ghostObject->restoreIfDisplacedFor(playerIndex); + } } else { //Record that this object was seen by the player. This info will be used to show fogged enemy faction buildings. @@ -1716,6 +1724,14 @@ ObjectShroudStatus PartitionData::getShroudedStatus(Int playerIndex) //need a ghost object. m_ghostObject->freeSnapShot(playerIndex); } + else if (m_ghostObject) + { + // Splitscreen: this seat can see it now but never fogged it, so the branch above + // cannot fire - and if ANOTHER local seat's snapshot is currently displacing the + // real object out of the shared scene, this seat sees nothing there at all. That is + // the "bunker/oil derrick visible in one viewport but not the other" case. + m_ghostObject->restoreIfDisplacedFor(playerIndex); + } } #ifndef DISABLE_INVALID_PREVENTION if (m_coiInUseCount && updateShroudednessPrevious) diff --git a/GeneralsMD/Code/GameEngineDevice/Include/W3DDevice/GameLogic/W3DGhostObject.h b/GeneralsMD/Code/GameEngineDevice/Include/W3DDevice/GameLogic/W3DGhostObject.h index 38b1485ee25..375916cacee 100644 --- a/GeneralsMD/Code/GameEngineDevice/Include/W3DDevice/GameLogic/W3DGhostObject.h +++ b/GeneralsMD/Code/GameEngineDevice/Include/W3DDevice/GameLogic/W3DGhostObject.h @@ -60,6 +60,8 @@ class W3DGhostObject: public GhostObject void removeParentObject(); void restoreParentObject(); ///< restore the original non-ghosted object to scene. Bool addToScene(int playerIndex); + virtual void restoreIfDisplacedFor(int playerIndex) override; + Bool removeFromScene(int playerIndex); Bool anyOtherLocalSeatSees(int playerIndex) const; ///< splitscreen: another local seat still has this object in sight, so it must stay in the shared scene. ObjectShroudStatus getShroudStatus(int playerIndex); ///< used to get the partition manager to update ghost objects without parent objects. diff --git a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameLogic/W3DGhostObject.cpp b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameLogic/W3DGhostObject.cpp index 7f1bc4f9db4..0af04ec9bd7 100644 --- a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameLogic/W3DGhostObject.cpp +++ b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameLogic/W3DGhostObject.cpp @@ -586,6 +586,27 @@ void W3DGhostObject::freeAllSnapShots() // ------------------------------------------------------------------------------------------------ /** Player has unfogged the object so he no longer needs the snapshot*/ // ------------------------------------------------------------------------------------------------ +void W3DGhostObject::restoreIfDisplacedFor(int playerIndex) +{ + // Nothing is standing in for the real object, so there is nothing to undo. + if (m_sceneSnapshotPlayer < 0) + return; + + // Only a viewport on this machine can need the real object back. A remote or AI player's + // vision has no bearing on what the shared scene must contain. + if (!isLocalSeatPlayer(playerIndex)) + return; + + // Take out whichever seat's snapshot is currently displacing it - not necessarily this seat's - + // and put the real object back. Same shape as the restore inside freeSnapShot, which this + // deliberately mirrors; the difference is only in how we got here. + for (Int i = 0; i < MAX_PLAYER_COUNT; i++) + removeFromScene(i); + m_sceneSnapshotPlayer = -1; + + restoreParentObject(); +} + void W3DGhostObject::freeSnapShot(int playerIndex) { if (m_parentSnapshots[playerIndex]) From 646754571e6a8087d12d6f4a62e19090eabad4a1 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:03:07 +0300 Subject: [PATCH 37/42] docs: session dropoff - #8, #9, #10 and #12 fixed; four verified on a pad Eleven commits. The previous dropoff's single 'decisive' question turned out to be decisive about the cursor RENDERER rather than the hint logic, and three pieces of handoff4's evidence are retracted here: the enemies-vs-own-units report, mousedOver=0, and the coordinate-space hypothesis. Records what is runtime-verified (click-select, placement, cursors) versus merely compiled (six of mirelle's thirteen have still never been run), why #11's size half is a considered NO-GO rather than an oversight, and the pad-vs-pad test plan the second controller unlocks. Co-Authored-By: Claude Opus 5 --- PatchNotes/DROPOFF_2026-08-06b.md | 182 ++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 PatchNotes/DROPOFF_2026-08-06b.md diff --git a/PatchNotes/DROPOFF_2026-08-06b.md b/PatchNotes/DROPOFF_2026-08-06b.md new file mode 100644 index 00000000000..7ad85c83927 --- /dev/null +++ b/PatchNotes/DROPOFF_2026-08-06b.md @@ -0,0 +1,182 @@ +# Session dropoff — 2026-08-06 (afternoon) + +Successor to `DROPOFF_2026-08-06.md`. Read that one only for history; this file supersedes its +open questions, **both of which turned out to be answered wrongly there**. + +## Where the code is — STILL NOTHING PUSHED + +| | | +|---|---| +| Mac clone | `/Users/administrator/GeneralsSplit`, branch `splitscreen-documents`, HEAD `4e175bab4` | +| Windows build | `C:\dev\GenSplit`, synced by git bundle over scp, same SHA | +| Windows run dir | `C:\dev\gxdata` — deployed binary is `4e175bab4`, 9169408 bytes | +| Remote | **none.** No fork, no push, no PR. Operator gated this deliberately and it still holds. | + +Build recipe unchanged (`vcvarsall amd64_x86` then `ninja -f build-Release.ninja z_generals`), with +one correction below. + +## The two questions the previous dropoff asked, and their real answers + +It said one question — move cursor vs plain arrow over terrain — "decides whether #10 is already +fixed or still broken". The operator answered "plain arrow". That answer was **decisive about +something else entirely**: + +* the pad seat's cursor renderer **could not draw the move cursor at all**, so the answer carried + no information about the hint logic. `cursorTextureFileName` chose the filename from + `numFrames`, which is always 1 because retail `Mouse.ini` declares no frame count for any cursor + (unbounded grep: zero hits). But `sccmove` ships ONLY as `sccmove0000.dds`..`0020.dds` with no + unnumbered file, as does `sccscroll`. The lookup missed, WW3D returned its 128x128 + missing-texture placeholder, the size guard correctly refused it, and `drawSeatCursor` + substituted ARROW. Fixed in `6afadc1e2`. +* the "works on enemies, not on my own units" report was **never about picking**. `SCCAttack` is + one of only three states that ship unnumbered texture art; `SCCSelect` ships none at all. That + observation should be struck from the record. +* `mousedOver=0 on every sample` is **not diagnostic**. `InGameUI.cpp` sets + `m_mousedOverDrawableID = INVALID_DRAWABLE_ID` in the terrain branch of `createMouseoverHint`, + so 0 is the correct value whenever the cursor is over ground — which is what was sampled. + handoff4 leans on it; it should not. +* handoff4's "leading hypothesis" of a coordinate-space mismatch is **refuted**. `getPickRay` + already subtracts `m_originX/m_originY` and normalises by the view's own size, and + `screenToTerrain` goes through `getPickRay`. Both halves agree. + +## What landed today — 11 commits + +| commit | what | runtime | +|---|---|---| +| `6afadc1e2` | seat cursor art: resolve numbered `.tga` when the unnumbered name is missing | **CONFIRMED** | +| `c2eb2d73b` | stop re-probing the cursor states with no art at all | perf only | +| `0a17bb649` | `GX_PICKALL` probe for #8 | superseded, removed | +| `66c286d51` | **#8/#10** — the point pick was answered against seat 0's vision | **CONFIRMED** | +| `478b07df8` | **#9** — only seat 0's building placement was ever updated per frame | **CONFIRMED** | +| `67801c8ca` | ghost draw gate disagreed with the ghost visibility filter | not verified | +| `3b48007c0` | one seat's placement wiped every other seat's build footprint (regression from `478b07df8`) | **CONFIRMED** | +| `a24f7b44e` | **#9** — every no-arg placement accessor forwards to a literal 0 | **CONFIRMED** | +| `cfd2eee20` | probe: report every seat's cursor, not just the first drawn | **CONFIRMED** | +| `4b117700a` | **#10** — seat cursors draw the `.ani` art for the 27 states with no texture | **CONFIRMED** | +| `4e175bab4` | **#12** — a seat could not see a building another seat had fogged | not verified | + +### The three root causes worth remembering + +**The point pick read render residue.** `castRay(testAll=false)` tests `Is_Really_Visible()`, a +flag `RTS3DScene::Visibility_Check` rewrites for every render object once per VIEW per frame from +that view's camera and player. `Display::drawViews` walks the view list head to tail and +`Display::attachView` PREPENDS, so seat 0's view — attached first — is drawn LAST and its answer is +the one standing when input is translated. Every other seat's click was therefore answered with +seat 0's vision. Drag-select was unaffected because the rect branch of +`iterateDrawablesInRegion` walks `TheGameClient->firstDrawable()` and never reads the flag — which +is exactly why it presented as "drag selects, click does not". Fix evaluates the predicate live +for the picking view. + +**Every no-arg placement accessor forwards to a literal 0.** Seven of them +(`InGameUI.cpp:3848-3961`). `handleBuildPlacementsForActiveSeat` read +`getPendingPlaceSourceObjectID()` no-arg, so a pad seat got seat 0's builder — `INVALID_ID` — +`BuildAssistant.cpp:936` left `playerIndex = -1`, and `PartitionManager.cpp:3258` returns +`CELLSHROUD_SHROUDED` for a negative player **before it looks at a cell**, so +`isLocationLegalToBuild` returned `LBC_SHROUD` at every position on the map. Red ghost everywhere, +by construction. Meanwhile `PlaceEventTranslator` read through `placeSeat` but four **writes** did +not, so a pad press armed seat 0's anchor and the click fell through to the command translator as +a move order. + +**Ghost displacement is one-sided.** `W3DGhostObject::snapShot` removes the REAL render object +from the shared scene under an `ownsScene` guard, but every restore path runs through +`freeSnapShot`, which needs the seat to HAVE a snapshot and needs its previous state to have been +FOGGED. A seat meeting the building for the first time has neither — it goes SHROUDED straight to +CLEAR — so nothing restored it and it stayed out of `RenderList`, invisible at any range. That is +#12. Fixed with a new `restoreIfDisplacedFor`. + +## Status against mirelle's thirteen + +| # | state | +|---|---| +| 1 communicator | implemented `4a0abf974`, **never runtime-tested** | +| 2/3 splash | implemented `3cd83c6e6` + `c2a26a4e8`, **never runtime-tested** | +| 4 bar scheme | implemented `ce879e9b5`, **never runtime-tested** | +| 5 observer HQ | implemented `7c67ce432`, **never runtime-tested** | +| 6 under-attack | implemented narrow `f06510e91`, **never runtime-tested**; two consequences logged, not fixed | +| 7 lasso | **verified** (previous session) | +| 8 click-select | **fixed and verified today** | +| 9 placement | **fixed and verified today** | +| 10 cursors | **fixed and verified today** (both the pick half and the art half) | +| 11 tooltip | routing landed `7c91c3323`; **SIZE HALF STILL OPEN — see below** | +| 12 civilian buildings | **fixed today**, awaiting verification | +| 13 shell radar | implemented `79adf2a5c`, **never runtime-tested** | + +**#11's size half is the only item of the thirteen that is not implemented**, and it is a +considered NO-GO rather than an oversight. Registering the tooltip layout into the bar's +`dockToRect` pass would make `dockToRect` re-apply authored geometry to it every frame, in single +view too. `populateBuildTooltipLayout` writes the parent size at `:667`, then has an early +`return` at `:682-684` when the marker is missing, then writes the position at `:701` and the +description box size at `:707`. Under registration a hover that takes that early return persists a +half-updated authored record forever. The function needs restructuring so it has no mid-way bail +before it can be registered. Verified by hand. + +## What to test next — pad vs pad + +The operator now has two controllers. **This matters**: nearly every bug found today was +"something resolves to seat 0", and that class is partly invisible when seat 0 is one of the two +participants. Seat 1 vs seat 2 is the configuration that has never been exercised. + +``` +cd /d C:\dev\gxdata && generalszh.exe -win -xres 1600 -yres 900 -splitscreendev 3 +``` + +Press A/Start on **each** pad and confirm `dev=` is non-negative on both seat 1 and seat 2 before +testing anything. Two runs were wasted today driving a synthetic seat (`dev=-101`). + +1. **#12** — bunkers and oil derricks, each pad walking up to one the other has fogged +2. **ghost gate** — a building both have scouted, both look away and back: grey stand-in in both +3. **#7** pad-vs-pad — drag on pad A, press on pad B mid-drag; A's box must vanish, not freeze +4. two pads placing simultaneously +5. **#1** communicator on a pad seat — must open in that seat's viewport AND its buttons respond +6. **#4** three factions, three distinct bar artworks; defeat player 1, only their bar changes +7. **#6** attack a pad-seat unit — warning in ITS viewport, ITS radar flashes +8. **#2/#3** one seat eliminated, the other two keep playing, no global input freeze +9. **#13** — change resolution **on the main menu**, watching the shell map. The in-game + resolution change was tested and is fine; that is a different code path. + +## Open findings, logged not fixed + +* **Wireless pad dying mid-match freezes the game ~1 minute.** Our own handlers are trivial + (`SeatManager::onDeviceRemoved` clears two fields; `closeGamepad` is `SDL_CloseGamepad` plus a + map erase). The signature is a blocking Windows HID call inside SDL for a Bluetooth device that + vanished. To settle it, get a stack DURING the hang (`procdump -ma -h`, or attach and break) and + see whether it is parked in `SDL_CloseGamepad`, the HID read, or ours. Workaround: do not + hot-swap pads mid-match. +* `W3DInGameUI.cpp:687` reads `isPlacementAnchored()` from RENDER code, where the seat is always 0. + Drives the single shared `m_buildingPlacementAnchor`/`m_buildingPlacementArrow`. Only affects the + line-build (wall) drag arrow. Making it per-seat is its own change. +* `setGUICommand` / `m_pendingGUICommand` is a flat non-seat member — one seat's selection can + still cancel another seat's pending GUI command. +* #6's two consequences: radar-event suppression is map-wide for 10s with no owner concept, and + radar blips leak across viewports. Both want an owner on `RadarEvent`, which is inside Radar's + xfer chain — conventions say stop and ask. +* `ControlBar.wnd` tree leaks one root per resolution change (`InGameUI.cpp:6841` looks up a name + no window has, so `deleteInstance` no-ops). +* 27 cursor states still have no *texture*; they now render from `.ani`. If any renders garbled or + mis-scaled, that is the ARGB upload or the row pitch in `findAniCursorImage`. + +## Traps learned today — do not re-learn these + +* **`vcvarsall.bat` does not take `-host_arch=x64`.** That is an `Enter-VsDevShell -DevCmdArguments` + flag. Passing it makes vcvarsall bail, the Windows SDK include paths never get set, and **every** + translation unit dies on `Cannot open include file: 'stdlib.h'` — which reads exactly like a code + error. Use `vcvarsall.bat amd64_x86`, and **never redirect its output to nul**; that is what hid + the failure for a whole build cycle. +* **A workflow script is plain JavaScript.** A backtick inside a template literal is a parse error, + and the script is not persisted when it fails to parse. +* `-splitscreendev N` is the **seat count**, not N+1. The overlay's `activeSeats=` is ground truth. +* `Get-Process generalszh` shows ~9 husks with `threads=0` and no path. Match on `ExecutablePath`; + a live game is the one WITH a path. `C:\dev\lib-safekill.ps1` does this correctly. +* The exe SHA moves every build. Prove a change landed with `dumpbin /SYMBOLS` on + `z_gameenginedevice.lib` / `z_gameengine.lib` for a symbol you introduced, and gate on ninja's own + exit code plus a relink (mtime moved). Size alone can legitimately be unchanged. +* PowerShell over SSH wraps `Write-Host` output in CLIXML noise. Use `Write-Output`, and filter + with `sed 's/ Date: Fri, 7 Aug 2026 00:49:51 +0300 Subject: [PATCH 38/42] splitscreen: the build progress readout showed its own format string Reported live, and visible on seat 0 as well as a pad seat: the control bar shows "Building:\n%.0f%%" verbatim while the world-space text over the structure shows the correct "Building: 51%". updateConstructionTextDisplay does format the string correctly. The defect is which window it writes to: winGetWindowFromId(nullptr, descID) is a GLOBAL name lookup, and with more than one ControlBar instance it reaches whichever copy the name resolves to - the newest head-inserted one. Every other bar therefore never had its description text written and kept the placeholder authored in ControlBar.wnd, which is literally the format string. That is why the symptom is an UNFORMATTED string rather than a stale or wrong number: nothing wrote to that window at all. It is also why seat 0 shows it too - seat 0's bar is not the newest instance either. Routed through findBarWindowById, which scopes strictly to this instance. No-op in single view, where the global lookup and the scoped one resolve to the same window. Note for whoever picks this up: this is bug class 1 and it is NOT isolated. An unbounded grep finds 22 remaining winGetWindowFromId(nullptr, ...) lookups under GameClient/GUI/ControlBar/. Each is a latent instance of exactly this, and any of them that writes rather than reads will present the same way - a control put in one bar and never in the others. They should be swept deliberately, with a build between, rather than in one blind pass. Also records that the #12 "still invisible" observation from the same session is INCONCLUSIVE: both pads were dropping in and out at the time, so nothing seen on screen can be attributed to the shroud code. Re-test on wired pads before concluding anything. Co-Authored-By: Claude Opus 5 --- .../ControlBarUnderConstruction.cpp | 8 +++- PatchNotes/DROPOFF_2026-08-06b.md | 39 ++++++++++++++++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarUnderConstruction.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarUnderConstruction.cpp index c150ac1baa6..ff4150ab95a 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarUnderConstruction.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarUnderConstruction.cpp @@ -49,7 +49,13 @@ void ControlBar::updateConstructionTextDisplay( Object *obj ) { UnicodeString text; static UnsignedInt descID = TheNameKeyGenerator->nameToKey( "ControlBar.wnd:UnderConstructionDesc" ); - GameWindow *descWindow = TheWindowManager->winGetWindowFromId( nullptr, descID ); + + // Splitscreen: THIS bar's window. A global winGetWindowFromId(nullptr, ...) returns whichever + // instance the name lookup happens to reach - the newest head-inserted copy - so with more than + // one ControlBar every other bar never had its text written and kept the placeholder authored + // in ControlBar.wnd, which is literally "Building:\n%.0f%%". That is why the symptom is an + // unformatted format string rather than a wrong number: nothing wrote to that window at all. + GameWindow *descWindow = findBarWindowById( (NameKeyType)descID ); // sanity DEBUG_ASSERTCRASH( descWindow, ("Under construction window not found") ); diff --git a/PatchNotes/DROPOFF_2026-08-06b.md b/PatchNotes/DROPOFF_2026-08-06b.md index 7ad85c83927..292ad3ef0fa 100644 --- a/PatchNotes/DROPOFF_2026-08-06b.md +++ b/PatchNotes/DROPOFF_2026-08-06b.md @@ -134,14 +134,41 @@ testing anything. Two runs were wasted today driving a synthetic seat (`dev=-101 9. **#13** — change resolution **on the main menu**, watching the shell map. The in-game resolution change was tested and is fine; that is a different code path. +## #12 — one observation, and why it is NOT evidence + +Late in the session the operator reported invisible objects still invisible, which would mean +`4e175bab4` did not fix #12. **Treat that as inconclusive, not as a confirmed failure.** The same +session had both pads dropping in and out (see below), so what was on screen cannot be attributed +to the shroud code. Re-test on wired pads before drawing any conclusion. + +What was ruled out while chasing it, so it need not be redone: + +* the per-seat shroud evaluation IS running. `GameClient.cpp:725-733` loops every seat and calls + `object->getShroudedStatus(seat->m_playerIndex)` for each, gated on `rts_isMultiSeatFogActive()` + which is `TheSeatManager->getBoundSeatCount() > 1` - true at 2 seats and above. So "seat N's + shroud is never evaluated" is not the explanation. +* `getShroudedStatus` is a LAZY evaluator and the snapshot/restore calls are its side effects. The + render path deliberately uses `peekShroudedStatus` and does not recompute, so the evaluation + loop above is the only thing keeping the per-seat cache and the ghost lifetime honest. + +If it does reproduce on wired pads, the next step is an env-gated probe reporting, per object: +`m_sceneSnapshotPlayer`, each seat's cached shroudedness, and whether `restoreIfDisplacedFor` +fires - not another round of source reading. Two mechanisms have already been found and fixed here +and a third would need measurement, not argument. + ## Open findings, logged not fixed -* **Wireless pad dying mid-match freezes the game ~1 minute.** Our own handlers are trivial - (`SeatManager::onDeviceRemoved` clears two fields; `closeGamepad` is `SDL_CloseGamepad` plus a - map erase). The signature is a blocking Windows HID call inside SDL for a Bluetooth device that - vanished. To settle it, get a stack DURING the hang (`procdump -ma -h`, or attach and break) and - see whether it is parked in `SDL_CloseGamepad`, the HID read, or ours. Workaround: do not - hot-swap pads mid-match. +* **Gamepad reliability over Bluetooth is the biggest practical blocker.** Two DualSense pads over + BT on this box: one froze the game ~1 minute when its battery died, the second worked for about + a minute and then stopped, and joining is intermittent. This is not the seat logic - + `SEAT_DEVICE_LOST` is only ever set from `onDeviceDisconnected`, reached only from SDL's + `GAMEPAD_REMOVED`, so SDL genuinely sees the device leave. Note the consequence: SDL assigns a + NEW joystick id on reconnect, so a seat that went `DEVICE_LOST` does not get its device back + automatically - the returning pad looks like a fresh device and must claim a seat again. + **USE WIRED PADS for any test session.** The one-minute freeze is a blocking Windows HID call + inside SDL for a vanished BT device; to settle that specifically, get a stack DURING the hang + (`procdump -ma -h`, or attach and break) and see whether it is parked in `SDL_CloseGamepad`, the + HID read, or ours. * `W3DInGameUI.cpp:687` reads `isPlacementAnchored()` from RENDER code, where the seat is always 0. Drives the single shared `m_buildingPlacementAnchor`/`m_buildingPlacementArrow`. Only affects the line-build (wall) drag arrow. Making it per-seat is its own change. From 5a3c7e49cff41702656869b36d9b9ade472ead97 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:19:04 +0300 Subject: [PATCH 39/42] docs: mouse-only test round - #13 and #4 artwork pass, two new findings VERIFIED: #13 (main-menu resolution change leaves the shell map clean), #4's artwork half, and the build-progress text fix. #10 comprehensively confirmed - one frame showed three seats drawing three DIFFERENT cursor states at once, covering the plain texture, the .ani decode and the numbered-frame art simultaneously. NEW: #11's position half is not fixed despite 7c91c3323 - seat 0's tooltip anchors to seat 2's bar. The lookups are correctly scoped; the anchor math applies a RUNNING DELTA against m_tooltipLastOffset instead of an absolute, which cannot be right across bars at different dock offsets. NEW: seat 2 renders terrain lit where its own radar shows it unexplored. The per-view render player is set correctly, so it is the shared destination shroud texture and the draw ordering it depends on - seat 2 is drawn first, so it is exactly the view that would show a later upload. Co-Authored-By: Claude Opus 5 --- PatchNotes/DROPOFF_2026-08-06b.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/PatchNotes/DROPOFF_2026-08-06b.md b/PatchNotes/DROPOFF_2026-08-06b.md index 292ad3ef0fa..d6676bd8059 100644 --- a/PatchNotes/DROPOFF_2026-08-06b.md +++ b/PatchNotes/DROPOFF_2026-08-06b.md @@ -156,6 +156,37 @@ If it does reproduce on wired pads, the next step is an env-gated probe reportin fires - not another round of source reading. Two mechanisms have already been found and fixed here and a third would need measurement, not argument. +## New findings from the mouse-only test round (2026-08-07) + +**VERIFIED PASSING this round:** #13 (resolution change on the main menu leaves the shell map +clean), #4's artwork half (three seats, three faction schemes, all docked at scale 0.50), and the +build-progress text fix. #10 is now comprehensively confirmed - one frame showed three seats each +drawing a DIFFERENT cursor state simultaneously: `seat0 type=2 SCCPointer.tga`, +`seat1 type=12 SCCNoAction.ani[0]`, `seat2 type=5 SCCMove0000.tga` - the plain texture, the .ani +decode and the numbered-frame art all working at once. + +**#11 position half is NOT fixed, despite `7c91c3323`.** Seat 0's build tooltip renders above +SEAT 2's bar. With bars docked at `(0,370)`, `(960,370)` and `(0,910)`, seat 0's tooltip appeared +at roughly y=620 - which is anchored to the wrong instance. The window lookups are NOT the problem; +they correctly use `findBarWindowById`. The anchor math is: `ControlBarPopupDescription.cpp:686-701` +mixes `getBackgroundMarkerPos()` (an AUTHORED coordinate captured once at init) with +`marker->winGetScreenPosition()` (a DOCKED one), scales the authored side by `getBarDockScale()`, +and then applies the result as a RUNNING DELTA against `m_tooltipLastOffset` rather than as an +absolute position. A running delta cannot be correct across several bars at different dock offsets. +Fix the anchor to be absolute; do not patch the delta. + +**Seat 2 can see under the shroud.** Its terrain renders lit where its own radar correctly shows +the map unexplored - so the shroud DATA is right per seat and the terrain TEXTURE BINDING is not. +Ruled out: the view's render player is set correctly (`InGameUI.cpp:6428`, +`v->setRenderPlayerIndex(si == 0 ? -1 : s->m_playerIndex)`), so the fill is for seat 2's own player. +What remains is the design's fragile point, admitted in `W3DDisplay::prepareShroudForView`'s own +comment: there is ONE shared destination shroud texture, and correctness depends on each view's +terrain drawing synchronously between its own upload and the next view's. Seat 2 is drawn FIRST +(attachView prepends, so seat 0 is last), which makes it precisely the view that would show a later +upload if anything defers or re-samples. The long-standing `RENDER@BIND` probe line +(`SeatManager.cpp:179`) has been reporting `objRenderPlayer=3 (should be seat-1 player, not local)` +and `TEXlvl=125(255=lit)` all along - start there. Needs a probe, not more source reading. + ## Open findings, logged not fixed * **Gamepad reliability over Bluetooth is the biggest practical blocker.** Two DualSense pads over From 6460b0bacb7c8209abe4de10109f953fdd4aebb0 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:26:09 +0300 Subject: [PATCH 40/42] docs: #4 passes outright, #2/#3 half passes; splash misposition narrowed to two candidates #4 VERIFIED in full: three seats showed three faction schemes, and on seat 0's defeat only seat 0's bar went observer while seats 1 and 2 kept theirs. The empty-looking bar in the first screenshot was simply nothing selected. #2/#3 half passes. The trigger fires and there is no global input freeze - seats 1 and 2 played on, which was the sharper claim. But the splash is centred on the whole display instead of the seat's viewport, and the operator reports money and team overlays doing the same, so it is broader than the finding. Three hypotheses were tried and all three are refuted in the doc, with the two survivors named: isSplitscreenEnabled() false at creation time, or info.windows empty so the transform loop iterates nothing. Both fall out of one probe. Stopping there rather than guessing a fourth time. Co-Authored-By: Claude Opus 5 --- PatchNotes/DROPOFF_2026-08-06b.md | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/PatchNotes/DROPOFF_2026-08-06b.md b/PatchNotes/DROPOFF_2026-08-06b.md index d6676bd8059..7aeec16e77c 100644 --- a/PatchNotes/DROPOFF_2026-08-06b.md +++ b/PatchNotes/DROPOFF_2026-08-06b.md @@ -187,6 +187,42 @@ upload if anything defers or re-samples. The long-standing `RENDER@BIND` probe l (`SeatManager.cpp:179`) has been reporting `objRenderPlayer=3 (should be seat-1 player, not local)` and `TEXlvl=125(255=lit)` all along - start there. Needs a probe, not more source reading. +## #2/#3 — HALF PASS, and the failing half is narrowed to two candidates + +Tested by selling seat 0's base to force a real defeat while seats 1 and 2 played on. + +**Passes:** the trigger fires, and there is NO global input freeze - seats 1 and 2 kept playing +normally. That was the sharper of the two claims and it holds. + +**Fails:** the splash is centred on the WHOLE DISPLAY, covering all four quadrants, instead of +sitting inside seat 0's viewport. The operator also reports the money and team overlays doing the +same, so this is broader than the finding: every full-screen overlay is placed for the whole +display rather than per seat. + +Ruled out by reading, do NOT redo these: + +* seat 0 is NOT on a legacy path. `ScriptActions.cpp:217/220/241/244/263` all call + `showOutcomeSplashForSeat( 0, ... )`, the same function seats 1..7 use via + `VictoryConditions.cpp:208/250`. +* seat 0 HAS a view. `InGameUI.cpp:6351` sets `s0->m_view = TheTacticalView`, so the + `localSeat->m_view == nullptr` early return is not it. +* the size guard would not bail. It returns only when the view IS the full display + (`viewW >= dispW && viewH >= dispH`); seat 0's viewport is 960x540 of 1920x1080. +* the function is NOT seat-0-special-cased by index. Its comment says seat 0 keeps the authored + placement, but the code only tests view-versus-display size, which is false for seat 0 in split. + +Two candidates remain, both cheap to settle with one probe in `showOutcomeSplashForSeat`: + +1. `TheSeatManager->isSplitscreenEnabled()` is FALSE at the moment the splash is created - it is a + plain `m_enabled` flag and nothing verified it is set during a `-splitscreendev` run. This + returns before ANY transform, for every seat. +2. `info.windows` is EMPTY, so the transform loop iterates nothing. The commit's own comment flags + that `winCreateFromScript` returns only the first root and that `info.windows` is what must be + transformed - but whether `winCreateFromScript` actually POPULATES that list was never verified. + +Log both values on entry and the answer falls out in one run. Note candidate 2 would also mean the +seats 1..7 splashes are mispositioned identically and nobody has looked at those yet. + ## Open findings, logged not fixed * **Gamepad reliability over Bluetooth is the biggest practical blocker.** Two DualSense pads over From 67814fdb2e1e1f2bf94136ea76313a4e5377c493 Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:32:53 +0300 Subject: [PATCH 41/42] docs: overlay positioning has a working reference; defeated seat can command another army The generals promotion screen is positioned CORRECTLY per seat while the defeat splash, score panel and pause menu all go full-display. That makes this one job rather than four bugs: the generals screen and the special-power shortcut bar register their layout with the seat's own ControlBar, which is the established mechanism. Port the others onto it instead of hand-rolling scale/offset maths per overlay - showOutcomeSplashForSeat already tried that and is the one that does not work. Likely covers #1 as well. Also records that after seat 0 was defeated its keyboard began commanding player 3's units while seat 2 was still playing that army. Mechanism located: the observer cycle-player paths call rts::changeLocalPlayer(), which reassigns the local player outright, and getCommandActingPlayer() falls back to exactly that. Whether it is pre-existing retail observer behaviour or a splitscreen regression is NOT established - A/B the baseline before fixing. Co-Authored-By: Claude Opus 5 --- PatchNotes/DROPOFF_2026-08-06b.md | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/PatchNotes/DROPOFF_2026-08-06b.md b/PatchNotes/DROPOFF_2026-08-06b.md index 7aeec16e77c..729e07958a6 100644 --- a/PatchNotes/DROPOFF_2026-08-06b.md +++ b/PatchNotes/DROPOFF_2026-08-06b.md @@ -223,6 +223,50 @@ Two candidates remain, both cheap to settle with one probe in `showOutcomeSplash Log both values on entry and the answer falls out in one run. Note candidate 2 would also mean the seats 1..7 splashes are mispositioned identically and nobody has looked at those yet. +## The overlay-positioning class, and the reference implementation that already works + +The operator swept the in-game overlays. Result: + +| overlay | placement | +|---|---| +| **generals promotion screen** | **CORRECT - inside its own seat's viewport** | +| end-of-match splash (#2/#3) | full display, covers all four quadrants | +| money / team score panel | full display | +| pause / main menu | full display | + +**The generals screen being right is the important half of this.** It means the mechanism already +exists, is proven in this codebase, and the broken overlays simply do not use it: the generals +screen and the special-power shortcut bar register their layout with the SEAT'S ControlBar +(`addBarLayoutWindows` + `redockAfterRootsChanged`), which handoff4 already identified as "the only +established mechanism for putting a non-ControlBar.wnd popup in a seat viewport". + +So this is not four separate positioning bugs to reason about from scratch. It is one job: port the +splash, the score panel and the menus onto the path the generals screen already takes. Read that +path first and copy it - do not invent a second way of doing it, and do not hand-roll scale/offset +maths per overlay (`showOutcomeSplashForSeat` already tried that and is the one that does not work). + +This also very likely covers finding **#1** (the communicator / diplomacy popup opening in the main +window), which is the same shape and is still untested. + +## A defeated seat can COMMAND another seat's army + +Reported live and not yet investigated beyond locating the mechanism. After seat 0 was defeated, +its keyboard input began selecting and commanding **player 3's** units - while seat 2 was still +actively playing that army. + +Mechanism located: the observer "cycle to next player" paths at `CommandXlat.cpp:3594` and `:4113` +call `rts::changeLocalPlayer(player)`, which reassigns `ThePlayerList`'s LOCAL player outright. +`getCommandActingPlayer()` falls back to `ThePlayerList->getLocalPlayer()` whenever there is no seat +override, so once a defeated seat starts observing it does not merely watch that player - it +becomes them, and its input carries their authority. + +NOT established, and it matters: whether this is pre-existing retail observer behaviour or a +splitscreen regression. A/B against the baseline before writing a fix. It is materially worse in +splitscreen either way, because in single player there is nobody else at the keyboard - here a +defeated player can drive a live opponent's army. Related: handoff4's note that +`getSeatIndexForPlayer` (watching) and `getCommandingSeatIndexForPlayer` (control) are deliberately +different senses, and anything that hands out input ownership must use the latter. + ## Open findings, logged not fixed * **Gamepad reliability over Bluetooth is the biggest practical blocker.** Two DualSense pads over From 59b5ab5fafe2ebea74681462113f8948158c359f Mon Sep 17 00:00:00 2001 From: wh1ter0se <62149665+wh1ter0se69@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:36:13 +0300 Subject: [PATCH 42/42] probe(#2/#3): measure the splash transform instead of guessing at it again The end-of-match splash is centred on the whole display instead of the seat's viewport. FIVE static hypotheses have now been refuted, all by reading: * seat 0 is not on a legacy path - ScriptActions.cpp:217/220/241/244/263 all call showOutcomeSplashForSeat(0, ...), the same entry seats 1..7 use. * seat 0 has a view - InGameUI.cpp sets s0->m_view = TheTacticalView. * the full-display size guard cannot bail - seat 0's viewport is 960x540 of 1920x1080. * isSplitscreenEnabled() is true - CommandLine.cpp:797 sets m_splitscreenEnabled = TRUE in parseSplitscreenDev, and GameEngine.cpp:608 forwards it to the seat manager. * info.windows is populated - winCreateFromScript ends with `if(info) *info = scriptInfo;` after pushing every parsed root. Every gate that could skip the transform is open, and the transform itself looks correct. That is the point to stop reasoning and measure, so this logs every gate, the view/display geometry, and for each root the before, the values written, and a READ BACK of what the window manager actually stored. If the readback matches what was set, the transform worked and something re-applies authored geometry afterwards - which makes it an ordering problem, not a maths one, and points somewhere entirely different. Instrumentation only, default OFF, GX_SPLASHPROBE=1. Co-Authored-By: Claude Opus 5 --- .../GameEngine/Source/GameClient/InGameUI.cpp | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index 69e50140837..e870c4d7d56 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -2509,6 +2509,22 @@ void InGameUI::showOutcomeSplashForSeat( Int seat, const AsciiString& wndFile ) GameWindow *root = TheWindowManager->winCreateFromScript( wndFile, &info ); m_seatContexts[ seat ].m_outcomeSplash = root; + // Probe (#2/#3): the splash is reported centred on the WHOLE display instead of the seat's + // viewport. FIVE static hypotheses have been refuted - seat 0 does reach this function + // (ScriptActions calls it), it does have a view (InGameUI.cpp sets m_view = TheTacticalView), + // the size guard cannot bail at 960x540 of 1920x1080, m_splitscreenEnabled IS set by + // -splitscreendev, and info.windows IS populated by winCreateFromScript. So stop reasoning and + // measure: this reports every gate and every transform actually applied. GX_SPLASHPROBE=1. + const Bool splashProbe = (getenv("GX_SPLASHPROBE") != nullptr); + if( splashProbe ) + seatLog("[GXSPLASH] seat=%d file=%s splitEnabled=%d seatNull=%d viewNull=%d roots=%d", + seat, wndFile.str(), + (Int)(TheSeatManager != nullptr && TheSeatManager->isSplitscreenEnabled()), + (Int)(TheSeatManager == nullptr || TheSeatManager->getSeat( seat ) == nullptr), + (Int)(TheSeatManager == nullptr || TheSeatManager->getSeat( seat ) == nullptr + || TheSeatManager->getSeat( seat )->m_view == nullptr), + (Int)info.windows.size()); + if( TheSeatManager == nullptr || !TheSeatManager->isSplitscreenEnabled() ) return; @@ -2521,6 +2537,11 @@ void InGameUI::showOutcomeSplashForSeat( Int seat, const AsciiString& wndFile ) const Int dispW = TheDisplay ? TheDisplay->getWidth() : viewW; const Int dispH = TheDisplay ? TheDisplay->getHeight() : viewH; + if( splashProbe ) + seatLog("[GXSPLASH] seat=%d view=%dx%d disp=%dx%d bailFullDisplay=%d", + seat, viewW, viewH, dispW, dispH, + (Int)(viewW <= 0 || dispW <= 0 || (viewW >= dispW && viewH >= dispH))); + // full-display view => authored placement is already right, leave it exactly alone if( viewW <= 0 || dispW <= 0 || (viewW >= dispW && viewH >= dispH) ) return; @@ -2551,6 +2572,19 @@ void InGameUI::showOutcomeSplashForSeat( Int seat, const AsciiString& wndFile ) win->winSetSize( newW, newH ); win->winSetPosition( newX, newY ); + + if( splashProbe ) + { + // Read BACK what the window manager actually stored. If these do not match newX/newY + // then something re-applies authored geometry after us and the transform is not the + // problem - the ordering is. + Int gotX = 0, gotY = 0, gotW = 0, gotH = 0; + win->winGetPosition( &gotX, &gotY ); + win->winGetSize( &gotW, &gotH ); + seatLog("[GXSPLASH] seat=%d root id=%d was=(%d,%d %dx%d) set=(%d,%d %dx%d) readback=(%d,%d %dx%d) scale=%.3f", + seat, (Int)win->winGetWindowId(), x, y, w, h, + newX, newY, newW, newH, gotX, gotY, gotW, gotH, targetScale); + } } }