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/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/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/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)); } 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/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/StdDevice/Common/StdLocalFileSystem.cpp b/Core/GameEngineDevice/Source/StdDevice/Common/StdLocalFileSystem.cpp index d47e473f4d2..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,10 +274,33 @@ 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)) { - 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); diff --git a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DSeatCursorRenderer.cpp b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DSeatCursorRenderer.cpp index 1eaf865db9a..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 @@ -102,15 +110,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 +138,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 +177,73 @@ 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) +{ + // 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; + + if (s_naming[cursorType] == UNRESOLVED) + { + ICoord2D size; + if (measureCursorTexture( cursorTextureFileName( info, 0, FALSE ), &size ) + && isPlausibleCursorSize( size.x, size.y )) + { + s_naming[cursorType] = UNNUMBERED; + } + 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) + { + s_naming[cursorType] = ABSENT; + } + } + + 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 +283,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 +304,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 @@ -253,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) @@ -275,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 ); } @@ -303,7 +524,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) { diff --git a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp index 67d94c4b855..e037c2605bf 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(); } @@ -2577,7 +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); - if( W3DDisplay::m_3DScene->castRay( raytest, false, (Int)pickType ) ) + // 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. 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. + // + // 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, 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/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/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/GameEngine/Include/GameClient/ControlBar.h b/GeneralsMD/Code/GameEngine/Include/GameClient/ControlBar.h index 80041681e04..77445af5f0c 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; @@ -993,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. */ @@ -1009,6 +1025,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 +1048,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; @@ -1167,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/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/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/Include/GameClient/InGameUI.h b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h index 0f3c496ef22..12469793230 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 @@ -405,6 +411,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" @@ -752,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 ]; @@ -788,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/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/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/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; } diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBar.cpp index b7bf17fa55f..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 @@ -981,6 +986,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; @@ -999,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; @@ -1511,6 +1523,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 ) @@ -2210,6 +2253,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; @@ -2339,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; } /* @@ -2582,7 +2631,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 ); } @@ -3701,11 +3752,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" ) ) ); + } } //------------------------------------------------------------------------------------------------- @@ -4865,6 +4935,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/ControlBarCommandProcessing.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/ControlBar/ControlBarCommandProcessing.cpp index e659684787c..12555a66c2f 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(); @@ -263,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; @@ -308,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 ) @@ -350,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/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/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/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/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; 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; slotNum 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() +{ + // 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 ) + { + if( m_seatContexts[ seat ].m_pendingPlaceType == nullptr ) + continue; + + setActiveSeat( seat ); + handleBuildPlacementsForActiveSeat(); + } + + setActiveSeat( prevActiveSeat ); +} + +//------------------------------------------------------------------------------------------------- +void InGameUI::handleBuildPlacementsForActiveSeat() { // @@ -1733,14 +1796,18 @@ 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() ) + 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; @@ -1751,8 +1818,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; @@ -1771,17 +1838,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 ); @@ -1796,9 +1863,10 @@ void InGameUI::handleBuildPlacements() // 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() ); + Object *builderObject = TheGameLogic->findObjectByID( getPendingPlaceSourceObjectID( m_activeSeat ) ); LegalBuildCode lbc; lbc = TheBuildAssistant->isLocationLegalToBuild( &world, @@ -1837,18 +1905,18 @@ void InGameUI::handleBuildPlacements() // 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; - 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; @@ -1857,7 +1925,7 @@ void InGameUI::handleBuildPlacements() 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 @@ -2229,6 +2297,15 @@ 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. + for( Int dragSeat = 0; dragSeat < MAX_SEATS; ++dragSeat ) + m_seatContexts[ dragSeat ].m_isDragSelecting = false; + // reset the command bar TheControlBar->reset(); @@ -2402,6 +2479,132 @@ 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; + + // 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; + + 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; + + 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; + + 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 ); + + 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); + } + } +} + +//------------------------------------------------------------------------------------------------- +/** 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 @@ -2585,7 +2788,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; } //------------------------------------------------------------------------------------------------- @@ -2662,6 +2878,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. @@ -2673,10 +2918,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) { @@ -2708,7 +2953,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; @@ -2859,7 +3108,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); @@ -2894,7 +3151,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 ); } } } @@ -2909,7 +3168,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())) @@ -2926,7 +3187,7 @@ void InGameUI::createMouseoverHint( const GameMessage *msg ) drawSelectable = false; } - if( drawSelectable && obj->isLocallyControlled() ) + if( drawSelectable && obj->isControlledByPlayer(getCommandActingPlayer()) ) { setMouseCursor(Mouse::SELECTING); } @@ -2952,8 +3213,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(); @@ -2961,7 +3236,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 @@ -2994,10 +3272,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) @@ -3038,8 +3316,18 @@ 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( 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; } @@ -3047,9 +3335,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 ); @@ -6855,6 +7143,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() diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp index 8d4525839bd..7a271378609 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp @@ -3230,7 +3230,8 @@ GameMessageDisposition CommandTranslator::translateGameMessage(const GameMessage case GameMessage::MSG_META_DIPLOMACY: if (TheGameLogic->isInGame() && !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); @@ -3906,7 +3907,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; diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/PlaceEventTranslator.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/MessageStream/PlaceEventTranslator.cpp index 0d6999bbd98..bd67f04ea18 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,32 +87,32 @@ 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; } // set this location as the placement anchor - TheInGameUI->setPlacementStart( &mouse ); + TheInGameUI->setPlacementStart( &mouse, placeSeat ); /* // @@ -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 | @@ -137,7 +151,7 @@ GameMessageDisposition PlaceEventTranslator::translateGameMessage(const GameMess { // start placement anchor - TheInGameUI->setPlacementStart(&mouse); + TheInGameUI->setPlacementStart(&mouse, placeSeat); } */ @@ -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 @@ -300,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 ); } @@ -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; @@ -339,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 7855f036188..b882078470c 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; @@ -916,6 +928,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; @@ -956,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() ) { @@ -1017,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; 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 ); } 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/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); 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 ); } 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/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/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/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 ); } 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 ); diff --git a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DScene.cpp b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DScene.cpp index a0eaf21d6c0..8fdad033f52 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(); @@ -844,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"); 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]) 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. diff --git a/PatchNotes/DROPOFF_2026-08-06b.md b/PatchNotes/DROPOFF_2026-08-06b.md new file mode 100644 index 00000000000..729e07958a6 --- /dev/null +++ b/PatchNotes/DROPOFF_2026-08-06b.md @@ -0,0 +1,320 @@ +# 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. + +## #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. + +## 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. + +## #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. + +## 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 + 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. +* `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/ **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`. **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 | +|---|---|---| +| #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` | +| #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 | + +**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. + +**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 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 + +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. + +## 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.) + +### 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 — 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. 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: +``` +generalszh!W3DDisplay::getDisplayModeCount+0xe [W3DDisplay.cpp @ 514] <- resolutions.Count() +generalszh!W3DDisplay::init+0x357 [W3DDisplay.cpp @ 870] +generalszh!GameClient::init+0x573 [GameClient.cpp @ 331] +``` +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 +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 +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. + +## 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. + +## 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 + `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".