Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
66e2f73
splitscreen: only seat 0's drag-select lasso was ever drawn
wh1ter0se69 Aug 5, 2026
7c67ce4
splitscreen: an AI spectator seat had the army it watches HQ-selected
wh1ter0se69 Aug 5, 2026
d14e21c
splitscreen: any seat's deselect cancelled player 1's building placement
wh1ter0se69 Aug 5, 2026
ce879e9
splitscreen: one player's defeat reskinned every seat's control bar
wh1ter0se69 Aug 5, 2026
79adf2a
splitscreen: the radar drew over the shell map after a resolution change
wh1ter0se69 Aug 5, 2026
45077fb
splitscreen: a pad seat's cursor was pinned to ARROW by its own selec…
wh1ter0se69 Aug 5, 2026
8b558a2
docs: handoff4 - verification round, 1 refuted, 6 partly wrong, 6 fix…
wh1ter0se69 Aug 5, 2026
6a0da01
docs: handoff4 - runtime attempt, fixes exonerated, two measurement t…
wh1ter0se69 Aug 5, 2026
f06510e
splitscreen: under-attack warnings only ever fired for player 1
wh1ter0se69 Aug 6, 2026
3bc73de
splitscreen: click-path probe for finding #8 (instrumentation, not a …
wh1ter0se69 Aug 6, 2026
3cd83c6
splitscreen: end-of-match splash covered every viewport, and seats 1-…
wh1ter0se69 Aug 6, 2026
47e0b86
docs: handoff4 - nine findings landed, test recipe, and why #1/#11 we…
wh1ter0se69 Aug 6, 2026
4a0abf9
splitscreen: the communicator always opened in the main window
wh1ter0se69 Aug 6, 2026
7c91c33
splitscreen: the build tooltip was one popup, on seat 0's bar, at sea…
wh1ter0se69 Aug 6, 2026
432d551
splitscreen: a pad seat armed a build through seat 0's context and ca…
wh1ter0se69 Aug 6, 2026
955adc6
docs: handoff4 - all findings landed, and the init crash root-caused …
wh1ter0se69 Aug 6, 2026
c06e8ed
docs: handoff4 - correct the status header and the SHA-verification a…
wh1ter0se69 Aug 6, 2026
aa7cd24
docs: handoff4 - localise the second crash to the D3D8 stub being loaded
wh1ter0se69 Aug 6, 2026
782c609
fix(filesystem): StdLocalFileSystem recursed forever, dropping the pa…
wh1ter0se69 Aug 6, 2026
9220e8c
fix(filesystem): one unrepresentable filename killed the whole engine
wh1ter0se69 Aug 6, 2026
7951c5c
fix(cmdline): -map was ignored unless last, and its value re-parsed a…
wh1ter0se69 Aug 6, 2026
5c93bff
docs: correct handoff4 - the mods were never the INI problem
wh1ter0se69 Aug 6, 2026
0d2fbc5
probe(#10): instrument the cursor-hint gates for a pad seat
wh1ter0se69 Aug 6, 2026
2039949
docs: handoff4 - first runtime verification; #7 passes, #8 and #10 ar…
wh1ter0se69 Aug 6, 2026
268c828
docs: session dropoff 2026-08-06
wh1ter0se69 Aug 6, 2026
6afadc1
splitscreen: a pad seat could never draw the move cursor, whatever th…
wh1ter0se69 Aug 6, 2026
0a17bb6
probe(#8): the point pick is answered against seat 0's visibility, no…
wh1ter0se69 Aug 6, 2026
c2eb2d7
splitscreen: stop re-probing cursor states that have no art at all
wh1ter0se69 Aug 6, 2026
66c286d
splitscreen: a pad seat's click was answered against seat 0's vision,…
wh1ter0se69 Aug 6, 2026
478b07d
splitscreen: only seat 0's building placement was ever updated, so no…
wh1ter0se69 Aug 6, 2026
67801c8
splitscreen: the ghost draw gate and the ghost visibility filter disa…
wh1ter0se69 Aug 6, 2026
3b48007
splitscreen: one seat's placement wiped every other seat's build foot…
wh1ter0se69 Aug 6, 2026
a24f7b4
splitscreen: the placement accessors all answered for seat 0, so a pa…
wh1ter0se69 Aug 6, 2026
cfd2eee
probe: report every seat's cursor, not just the first one drawn
wh1ter0se69 Aug 6, 2026
4b11770
splitscreen: seat cursors draw the .ani art for the 27 states that sh…
wh1ter0se69 Aug 6, 2026
4e175ba
splitscreen: a seat could not see a building another seat had fogged,…
wh1ter0se69 Aug 6, 2026
6467545
docs: session dropoff - #8, #9, #10 and #12 fixed; four verified on a…
wh1ter0se69 Aug 6, 2026
43e74e1
splitscreen: the build progress readout showed its own format string
wh1ter0se69 Aug 6, 2026
5a3c7e4
docs: mouse-only test round - #13 and #4 artwork pass, two new findings
wh1ter0se69 Aug 6, 2026
6460b0b
docs: #4 passes outright, #2/#3 half passes; splash misposition narro…
wh1ter0se69 Aug 6, 2026
67814fd
docs: overlay positioning has a working reference; defeated seat can …
wh1ter0se69 Aug 6, 2026
59b5ab5
probe(#2/#3): measure the splash transform instead of guessing at it …
wh1ter0se69 Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion Core/GameEngine/Include/Common/GameUtility.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);

Expand Down
3 changes: 2 additions & 1 deletion Core/GameEngine/Include/Common/RenderLeakProbe.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
19 changes: 19 additions & 0 deletions Core/GameEngine/Source/Common/GameUtility.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
38 changes: 26 additions & 12 deletions Core/GameEngine/Source/Common/RenderLeakProbe.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion Core/GameEngine/Source/Common/SeatManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
28 changes: 21 additions & 7 deletions Core/GameEngine/Source/Common/System/Radar.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) )
Expand All @@ -1070,15 +1082,15 @@ 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;
}
else
{
// display message
TheInGameUI->message( "RADAR:UnitUnderAttack" );
TheInGameUI->messageForSeat( seat, "RADAR:UnitUnderAttack" );

// play audio event
unitAttackSound = TheAudio->getMiscAudio()->m_radarStructureUnderAttackSound;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
37 changes: 36 additions & 1 deletion Core/GameEngineDevice/Include/SDL3Device/GameClient/SDL3Cursor.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,41 @@
#include "Lib/BaseType.h"

#include <array>
#include <vector>
#include <SDL3/SDL.h>

#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<UnsignedByte> 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<CursorFrameRGBA> m_frames;
Int m_hotSpotX;
Int m_hotSpotY;

AnimatedCursor()
: m_cursor(nullptr)
: m_cursor(nullptr), m_hotSpotX(0), m_hotSpotY(0)
{}
~AnimatedCursor()
{
Expand All @@ -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
Expand All @@ -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);

Expand Down
46 changes: 46 additions & 0 deletions Core/GameEngineDevice/Source/SDL3Device/GameClient/SDL3Cursor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
#include "Common/FileSystem.h"
#include "SDL3Device/GameClient/SDL3Cursor.h"

#include <cstring> // memcpy, for the retained cursor frames

AnimatedCursor* SDL3CursorManager::m_cursorResources[Mouse::NUM_MOUSE_CURSORS][MAX_2D_CURSOR_DIRECTIONS] = {nullptr};

void SDL3CursorManager::init()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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();
}
Loading