Skip to content

splitscreen: 6 of 13 findings fixed and verified on hardware; root causes and remaining work documented - #3

Draft
wh1ter0se69 wants to merge 42 commits into
githubawn:splitscreen-documentsfrom
wh1ter0se69:splitscreen-documents
Draft

splitscreen: 6 of 13 findings fixed and verified on hardware; root causes and remaining work documented#3
wh1ter0se69 wants to merge 42 commits into
githubawn:splitscreen-documentsfrom
wh1ter0se69:splitscreen-documents

Conversation

@wh1ter0se69

Copy link
Copy Markdown

Splitscreen findings from PatchNotes/ — 42 commits on top of f72603e6c.

Read the verification column before relying on anything here. Six of the thirteen findings have been exercised on real hardware with real gamepads. Several have not been run at all. That distinction is the most important thing in this description, and it is the reason this is opened for review rather than presented as finished.

Status against the thirteen

# finding state
1 communicator opens in the main window implemented, never run
2/3 end-of-match splash half: trigger and no-freeze verified, position still wrong
4 control-bar theme lost on player 1's defeat verified
5 AI observer has HQ auto-selected implemented, never run
6 under-attack text only for player 1 implemented narrow, never run
7 gamepad lasso not drawn verified
8 gamepad can only select by dragging verified
9 gamepad selection drops player 1's placement verified
10 cursors don't switch shape verified
11 build tooltip mispositioned and oversized still broken — see below
12 civilian buildings hidden from gamepad 1 implemented, verification inconclusive
13 radar over the shell map after a resolution change verified

The root causes, since they generalise

The point pick was answered against seat 0's vision. RTS3DScene::castRay(testAll=false) tests Is_Really_Visible(), which 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 while attachView prepends, so seat 0's view is drawn last and its answer is the one standing when input is translated. Drag-select was unaffected because the rect branch of iterateDrawablesInRegion iterates all drawables and never reads the flag — which is exactly why it presented as "drag works, click does not". Fixed by evaluating the predicate live for the picking view; single view takes the original path unchanged.

Every no-arg placement accessor forwards to a literal 0. Seven of them. The per-frame ghost updater read getPendingPlaceSourceObjectID() no-arg, so a pad seat got seat 0's builder — INVALID_IDBuildAssistant left playerIndex = -1, and getShroudStatusForPlayer returns CELLSHROUD_SHROUDED for a negative player before it examines a cell, so every position on the map returned LBC_SHROUD and the placement ghost was red everywhere by construction. Separately PlaceEventTranslator read through the acting seat 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.

Per-frame UI work ran only for seat 0. handleBuildPlacements resolves through m_activeSeat, which is only non-zero during message translation — the header says so directly: "Render/HUD code always runs with m_activeSeat == 0". So the placement ghost was never positioned or legality-checked for any other seat.

Ghost displacement is one-sided. snapShot removes the real render object from the shared scene when the last local seat loses sight of it, but every restore path runs through freeSnapShot, which requires that seat to have a snapshot and to have been previously 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 the render list, invisible at any range.

Seat cursors had no art. The renderer chose its texture filename from CursorInfo::numFrames, always 1 because retail Mouse.ini declares no frame count for any cursor — but sccmove and sccscroll ship only as numbered frames. Further, 27 of the 37 cursor states ship no texture at all, only Data\Cursors\*.ani and Art\W3D\*.W3D. Seat 0 is unaffected because it runs RM_WINDOWS. The engine already decodes every one of those .ani files at startup and then discarded the pixels; they are now retained and uploaded to a texture on first use. No art is copied into the repository — it is decoded from the player's own installed data at runtime.

Still open, with what is known

TheSuperHackers#11 size half — not implemented, deliberately. Registering the tooltip layout into the bar's dockToRect pass would make it re-apply authored geometry every frame, in single view too. populateBuildTooltipLayout writes the parent size, then has an early return when the background marker is missing, then writes the position and description-box size. Under registration, a hover taking that early return persists a half-updated authored record permanently. The function needs restructuring to have no mid-way bail before it can safely be registered.

TheSuperHackers#11 position half — still broken despite the commit claiming it. Observed live: seat 0's tooltip renders above seat 2's bar. The window lookups are correctly scoped; the anchor math mixes an authored marker coordinate with a docked screen position and applies the correction as a running delta against m_tooltipLastOffset rather than as an absolute, which cannot be correct across bars at different dock offsets.

Overlay positioning — one job, not four bugs. The end-of-match splash, the money/score panel and the pause menu are all placed for the whole display rather than the seat's viewport. Crucially the generals promotion screen is correct, which means the mechanism already exists and works: it registers its layout with the seat's own ControlBar (addBarLayoutWindows + redockAfterRootsChanged). The others should be ported onto that path rather than hand-rolling scale/offset maths per overlay — showOutcomeSplashForSeat tried that and is the one that does not work. This likely covers #1 as well. A probe is included (GX_SPLASHPROBE=1) because five static hypotheses about the splash were each refuted by reading; the sixth needs measurement.

A defeated seat can command another seat's army. After seat 0 was defeated its keyboard began selecting and commanding player 3's units while another seat was actively playing that army. The observer cycle-player paths call rts::changeLocalPlayer(), which reassigns the local player outright, and getCommandActingPlayer() falls back to exactly that. Not established whether this is pre-existing observer behaviour or introduced here — it wants an A/B against the baseline before anyone writes a fix.

Seat 2 renders terrain lit where its own radar shows it unexplored. The per-view render player is set correctly, so the suspicion is the single shared destination shroud texture and the draw ordering prepareShroudForView depends on. Its own comment admits that fragility.

ControlBar global window lookups. One was found and fixed live — the build progress readout displayed its own format string, because a global winGetWindowFromId(nullptr, ...) reaches the newest bar instance and every other bar kept the placeholder authored in ControlBar.wnd. An unbounded grep finds 22 more such lookups under GameClient/GUI/ControlBar/. Each that writes rather than reads is a latent instance of the same bug. They want a deliberate sweep with a build between, not one blind pass.

Gamepad reliability over Bluetooth is the biggest practical obstacle to further testing, and is environmental rather than a defect here: a pad whose battery died froze the game for about a minute inside a blocking Windows HID call, and reconnects arrive with a new SDL joystick id so a seat that went DEVICE_LOST does not automatically recover its device. Wired pads are recommended for any test session.

Notes

Generals/ is not buildable on this branch and was not made so; only the Zero Hour target is built. Every change is intended to be a no-op with a single seat, and each commit message states why. Several commits also record diagnoses from earlier handoffs that turned out to be wrong — the coordinate-space hypothesis for the pick, the "enemies work but own units don't" report, and mousedOver=0 as evidence — because those were confidently written with accurate file:line citations and cost real time before being refuted by measurement.

wh1ter0se69 and others added 30 commits August 6, 2026 02:08
W3DInGameUI::draw() runs once per frame rather than once per view, and it
tested m_seatContexts[0].m_isDragSelecting alone - so a pad seat's lasso was
tracked correctly and then never painted. drawSelectionRegion() likewise read
seat 0's region unconditionally. It now takes a seat and draw() loops every
seat, skipping any seat>0 that has lost its viewport mid-drag so it cannot
paint a frozen box. The region is already in absolute screen pixels, so no
per-seat transform is needed.

Drawing those seats exposes a latent bug that was invisible while nothing
painted them: m_dragSelecting/m_dragSeat in SelectionTranslator are ONE state
machine shared by all seats, so a second seat pressing steals the drag and
leaves the first seat's m_isDragSelecting stuck TRUE - permanently, because
that seat's own button-up then takes the else branch and never calls
endAreaSelectHint. RAW_MOUSE_LEFT_BUTTON_DOWN now hands the previous owner's
lasso back first, via a new InGameUI::endAreaSelectHintForSeat() (the seat
being ended is not the seat being translated, so m_activeSeat cannot name it -
same reason c2a26a4 needed messageForSeat).

reset() also clears every seat's drag flag, which nothing did before, so a
lasso live at match end cannot survive into the next match.

Single-view is byte-identical: seats 1-7 never set the flag, the pre-emption
branch cannot fire with one seat, and endAreaSelectHint still resolves
m_activeSeat for all five existing callers.
findAndSelectCommandCenter resolved the seat with getSeatIndexForPlayer(),
which answers 'whose viewport shows this player', not 'who is playing it'.
An observer seat watching a live AI resolves to a real seat index exactly
like a human seat, so the watched army's command centre was selected into
the spectator's own selection context - and since round 3k made every
ControlBar correctly read its own seat's selection, the now-correct
per-seat highlighting faithfully displayed it for the whole match.

Rather than change getSeatIndexForPlayer(), which several callers rightly
want the watching semantic from, this adds getCommandingSeatIndexForPlayer()
as a thin filter over it: same answer, except an observer seat returns -1.
The defeat broadcast in VictoryConditions.cpp:213 deliberately keeps the
watching form - an observer viewport SHOULD show 'Player N has been
defeated' for the AI it is watching - which is the call site that would
have silently regressed had the exclusion gone inside the shared function.
Corrected that function's doc comment, which claimed 'commands' while the
body has always meant 'watches'.

GameLogic::selectObject takes the commanding form too, so no logic-driven
selection of any kind can land in a spectator's context.

Single-view is unchanged on every path: seat 0 can never be an observer
(reset clears m_observer, bindFakeSeats only marks seats >= 1, takeOverSeat
clears it), so the guard is never taken, and under !RTS_SDL3_ENABLE the new
function is a pure passthrough.
ControlBar::onDrawableDeselected called the legacy 2-arg placeBuildAvailable,
which forwards to a literal seat 0 (InGameUI.cpp:3458) rather than to
m_activeSeat like the selection family does. Dispatch into the function was
already correct - ControlBarInstances::get(seat) routes seat 1's deselect to
seat 1's bar - so seat 1's own deselect ran and then cleared SEAT 0's armed
placement as a side effect. m_seatIndex was already in scope 11 lines above.

Two more instances of the same call, both missed by the handoff and both
fixed here because they are clear-only and cannot desynchronise anything:
ControlBar::processCommandUI runs its clear BEFORE the command switch, so any
seat pressing any control-bar button cancelled player 1's placement; and
CommandXlat's right-click cancel had cmdSeat resolved and in use on the lines
immediately above it.

DELIBERATELY NOT FIXED HERE - the ARM sites (ControlBarCommandProcessing.cpp
GUI_COMMAND_DOZER_CONSTRUCT and the two special-power variants). Arm and
consume are both pinned to seat 0 today, which is why a pad seat's build
currently completes at all, wrongly, through seat 0's context. Passing
m_seatIndex to the arm calls alone would write the pending placement into
seat N's context while PlaceEventTranslator still reads seat 0's - so seat N
would arm a build it could never place, which is worse than the present bug.
That pair has to move together, and PlaceEventTranslator also projects
through TheTacticalView, so seat N's pixels would go through seat 0's camera.
Logged as its own finding rather than half-landed here.

Single-view is unchanged: every seat resolves to 0.
Every seat's ControlBar points at TheControlBar's single
ControlBarSchemeManager (initAsSeatInstance), and the paint callbacks drew
through that manager's m_currentScheme - which only ever holds whatever
scheme was applied LAST, by any bar. So when player 1 was defeated and
killPlayer set the blank FactionObserver skin, all eight viewports went
blank; and in general eight players on different factions all showed one
faction's artwork.

Each bar now records the scheme it was given, at the multiplier it was given
with, and draws with its own: ControlBar::setBarScheme, called from a new
ControlBarSchemeManager::applyCurrentSchemeToTargetBar that replaces the five
identical 'm_currentScheme->init( takeApplyToBar() )' statements. The
multiplier is captured verbatim rather than recomputed, because
setControlBarScheme uses integer division while the two by-player paths cast
to Real - recomputing would have silently changed the by-name/shell path.
ControlBarScheme::drawForeground/drawBackground are pure reads of m_layer[],
so several bars drawing one scheme object at different offsets is safe.

This also removes the setDrawScale() writes from inside the two draw
callbacks. Those were mutating shared manager state from a paint path to work
around the same root cause; the scale is now simply passed in.

Second, orthogonal bug fixed in the same area: because killPlayer's reskin is
behind isLocalPlayer(), which is never true for a seat>0 player, a defeated
SEAT player's bar never became an observer bar at all. applySchemeForBarPlayer
latched on player template alone, and a defeated player keeps their template.
It now also latches on isPlayerActive(). Read client-side from the player, so
no sim code is asked about seats - Player.cpp is deliberately untouched, per
the conventions rule against sim code reading seat state.

Single-view: with one bar every apply path already ends in init(TheControlBar),
so the recorded pair is by construction the pair the manager held; dock scale
is 1; both m_currentScheme and m_barScheme start null and both draws no-op.
The applySchemeForBarPlayer change is reached only from syncToSeats over
seats 1..7 and is unreachable with one seat.
Not the structural cause the handoff proposed. It claimed recreateControlBar
rebuilds three window roots while HideControlBar can only reach one, so the
radar and RightHUD were structurally unreachable. Extracting the shipped
ControlBar.wnd from WindowZH.big shows exactly ONE column-0 WINDOW block,
ControlBarParent; LeftHUD (the radar's DRAWCALLBACK) and RightHUD are its
CHILDren. Hiding the parent hides the radar - drawWindow and isHidden both
stop at a hidden ancestor.

The real cause is a stale-instance resolution. recreateControlBar calls
createControlBar(), whose HideControlBar() runs BEFORE 'delete TheControlBar'
- so actingControlBar() still resolves the OLD bar, and
ControlBar::findBarWindowById scopes strictly to that instance's roots and
returns the OLD ControlBarParent. The seat-0 global fallback never runs
because the scoped search succeeded. Net: the outgoing, already-hidden root
is hidden again and the incoming one - authored ENABLED - stays visible.

This is handoff2 5.2's bug class 1 arriving from the opposite direction: not
a global lookup returning an arbitrary instance, but a scoped lookup pinned
to a dead one. Before splitscreen the global winGetWindowFromId happened to
land on the newest root and this worked by accident.

Fixed at the end of recreateControlBar, after init() has given the new bar
its roots, so both call sites (OptionsMenu and MainMenu) and any future one
are covered. Deliberately did NOT take the handoff's first proposal of
guarding those two call sites: window geometry is baked from the live display
size at parse time and the ControlBar layout is never rebuilt at match start,
so skipping the rebuild would leave the bar sized for the old resolution for
the rest of the process - a quieter, worse bug. Guard text copied verbatim
from the idiom already in OptionsMenu.cpp.
…tion

The handoff's diagnosis for this one does not hold. It says seat 0's mouse
resting on its own HUD force-resets every other seat's cursor via the
TheMouse->getMouseStatus() reads in createCommandHint/createMouseoverHint.
getWindowUnderCursor is seat-aware - all three of its window loops skip any
window winSeatOwnsWindow() rejects - so for seat N the lookup is essentially
always null and underWindow is stuck FALSE. The claimed symptom is the
inverse of what that code can produce.

The dominant defect is Object::isLocallyControlled(), which compares against
ThePlayerList->getLocalPlayer() - seat 0's player - and is read four times
across the two functions. The decisive one gates the MOUSEMODE_DEFAULT early
return: getSelectCount()/getAllSelectedDrawables() are correctly per-seat, so
a seat with exactly ONE of its own units selected yields a non-null srcObj
that fails isLocallyControlled(), takes setMouseCursor(ARROW) and returns.
With two or more selected, srcObj is null and control falls through to the
message-type switch - which predicts a distinctive signature to check against
the live repro: the cursor is stuck with one unit selected but changes shape
with two.

All four now ask isControlledByPlayer(getCommandActingPlayer()), the pattern
already used in SelectionXlat and CommandXlat. Exact identity in single view:
getCommandActingPlayer() returns the local player whenever the seat override
is unset, which is what isLocallyControlled() compares against.

The getMouseStatus reads are still wrong and are fixed too, via a
getSeatHoverPixel() helper - seat 0 reads TheMouse verbatim, a pad seat reads
its own virtual cursor, which is already display-space and clamped to its
viewport.

Shroud gating: createCommandHint's getObservedOrLocalPlayer read is swapped
unconditionally (it early-returns on playback, so the observer case is
unreachable); createMouseoverHint's is seat-gated instead, because that
function has NO playback guard and an unconditional swap would change
replay-observer tooltips to the local player's shroud.

The three OS-mouse tooltip writes are now seat-0-only - the tooltip belongs to
the pointer, and a pad seat's hover was clearing and rewriting player 1's.
…es landed

Records what the pre-implementation verification sweep found, because the
refutations are worth more than the fixes and will be re-derived otherwise.

The headline: handoff3's 12 open findings had accurate file:line citations
and inaccurate reasoning about them. TheSuperHackers#8's prescribed fix is already in the
tree and its stated mechanism is architecturally impossible; TheSuperHackers#2/TheSuperHackers#3's would
have written only entry [0] of a MAX_SEATS array; TheSuperHackers#9's would have left a pad
seat unable to place anything at all; TheSuperHackers#10's and TheSuperHackers#13's named causes cannot
produce the reported symptoms; TheSuperHackers#11 is partly dead code behind a static that
is never assigned.

Also records the build recipe that actually works (VS-bundled cmake, not
whatever is on PATH), three SSH/PowerShell failure modes that exit 0 while
doing nothing, six new findings nobody had written down, and the probe TheSuperHackers#8
needs before it can be diagnosed at all - the existing input log is
structurally blind to click messages.
…raps

A fresh build of this branch does not start on the test box, and the BASELINE
commit f72603e crashes identically with none of this round's fixes in it -
which is what clears them. Both stdout and stderr are 0 bytes and no DXVK log
appears, so it dies before the graphics device or any engine logging; the
pre-existing GeneralsX binary emits ~156KB and a DXVK log on the same box with
the same env and data.

Records two measurement traps that each produced a confidently wrong answer
before being caught. Get-Process is not a liveness test here - a crashed
instance stays alive holding the Technical Difficulties modal, which scored
crashes as successes and manufactured a non-monotonic seat-count 'boundary'
and a bogus conclusion that -splitscreendev was to blame. And launching over
plain SSH always dies 0xC0000005 whatever the binary, so an SSH-vs-task
comparison is not an A/B at all. Ground truth is ReleaseCrashInfo.txt's mtime
against the run start.

Also root-causes the STATUS_DLL_NOT_FOUND seen when relocating the exe: the
build links against its own binkw32/mss32 under _deps, which differ by hash
from the ones in the existing run dir.
Two stacked defects. Object::attemptDamage gated the radar event on
isLocallyControlled(), which compares against ThePlayerList's local player,
so the event never fired at all for a seat>0 unit. And inside
Radar::tryUnderAttackEvent every consumer resolved seat 0: the message feed,
the radar frame glow, and the EVA branch.

The gate now asks getSeatIndexForPlayer(...) >= 0 - does ANY local seat
command this player - kept last in the && chain so the seat scan is only
reached after m_radarData != nullptr has culled most objects. Deliberately
NOT done by changing Object::isLocallyControlled() (28 callers) nor by adding
an isLocallyControlledByAnySeat() to Object's public API, where sim code
could reach for a seat concept.

Inside tryUnderAttackEvent the concerned player is resolved once, and all
four message() calls route through messageForSeat() - the handoff names only
three; the fourth is the generic 'RADAR:UnderAttack' else branch, and without
it the default case still lands in seat 0's viewport. triggerRadarAttackGlow
now flashes the concerned seat's own bar (ControlBarInstances::get returns
nullptr for an unregistered seat, unlike fromWindow, so the fallback to
TheControlBar is mandatory) - that site is not in the handoff either, and
without it the wrong radar keeps blinking. The EVA test, which was a third
instance of isLocalPlayer() and is dead today because the old gate
guaranteed it true, becomes live and correct.

DELIBERATELY NOT FIXED, logged in handoff4 instead: widening the gate means
every seat can now raise a radar event, and tryEvent dedups map-wide for 10s
under PRESERVE_RADAR_WARNING_SUPPRESSION - so seat 0 being attacked can
swallow another seat's warning. And W3DRadar::drawEvents has no owner filter,
so blips leak across viewports. Both want an owner on RadarEvent, which sits
in Radar's xfer chain; the conventions say stop and ask before touching that.
…ntation, not a fix)

handoff3's stated cause for TheSuperHackers#8 is refuted - getWindowUnderCursor IS seat-aware
and its doc comment names that exact hypothesis as the bug it was added to
kill - and its stated verification cannot be performed, because the translator
trace filters to msg type >= MSG_BEGIN_META_MESSAGES (177) while
MSG_MOUSE_LEFT_CLICK is 163. splitscreen_input.log has never been able to see
a click.

Adds the two readings that discriminate the surviving hypotheses, both gated
on GX_CLICKPROBE so a normal build pays nothing:

  [GXPICK]  in W3DView::pickDrawable - the acting seat, whether
            getWindowUnderCursor returned a window at that pixel, its id, and
            whether an opaque window is what refused the pick.
  [GXCLICK] in SelectionXlat at the empty-list break - the message's seat tag,
            getCommandActingSeat(), isPoint, the pixel region, and how many
            drawables the region actually yielded.

Reading them: window non-null and owned by the acting seat's own bar => the
narrowed window theory; window non-null via getWindowUnderCursor's
m_grabWindow/m_mouseCaptor early return (which sit BEFORE any seat filter, and
are swapped per seat by winBeginSeatInput) => a stale per-seat grab, which is
the best surviving explanation; both null => the ray-cast itself missed;
isPoint FALSE => the click never entered the pick path and the finding is
misfiled.

No fix is attempted - handoff3's own instruction was to get a repro before
committing to a shape, and that is still right.
…7 never got one

The handoff's fix shape for this would have shipped a no-op. It said to make
ScriptActions::m_messageWindow a [MAX_SEATS] array and resolve
getSeatIndexForPlayer 'for the player the script action concerns'. There is no
such player at those call sites, and only entry [0] would ever be written: the
MP victory/defeat SCRIPTS are appended to side 0 alone (GameLogic.cpp:1607)
and their conditions resolve through m_localSlotNum, so doVictory/doDefeat/
doLocalDefeat fire at most once per match, for seat 0's player. Seats 1..7
never reach ScriptActions at all. TheScriptEngine->getCurrentPlayer() is live
there but is side 0's player, so threading it in would have looked correct
while mislabelling every splash.

So this needs two halves, and does both.

(a) Positioning. The splash pointer moves out of the file-scope static in
ScriptActions and into SeatUIContext (Pattern B, as c2a26a4 did for the
message feed), behind InGameUI::showOutcomeSplashForSeat(). A seat whose view
is smaller than the display gets the layout scaled and centred into its own
viewport, using the same root-takes-the-transform mapping as
ControlBar::dockToRect. winCreateFromScript returns only the first root, so
the WindowLayoutInfo out-param is used to reach them all.

(b) The missing trigger. VictoryConditions::update is the one place that
already detects defeat per player and victory per alliance, and c2a26a4
already wired it to getSeatIndexForPlayer. Seats > 0 now get LocalDefeat.wnd
on elimination and Victorious/Defeat.wnd when the match resolves.

The seat>0 path deliberately runs NOTHING else from doLocalDefeat -
doDisableInput, closeWindows, startCloseWindowTimer, SetVictorious and
markMPLocalDefeatWindowShown are all machine-global. Seat 3 losing must not
freeze seats 0-2 or end the match, and leaving MPLocalDefeatWindowShown alone
keeps seat 0's later victory as Victorious.wnd rather than ObserverQuit.wnd.

Single-view is byte-identical: the transform is gated on splitscreen being
enabled AND the seat's view being strictly smaller than the display, which is
never true for seat 0; the new triggers are guarded > 0.
…erHackers#11 were held

Adds the per-finding test recipe mirelle asked for, leading with the two
falsifiable predictions: TheSuperHackers#10's cursor should be stuck with exactly ONE unit
selected and fine with two or more, and TheSuperHackers#7's pad lasso should DISAPPEAR rather
than freeze when seat 0 pre-empts the drag. If either fails the diagnosis is
wrong and should be re-opened rather than patched around.

Records why #1, TheSuperHackers#11 and TheSuperHackers#9's arm/consume pair were deliberately not attempted:
each is large, none is runtime-verifiable from a Mac, and each fails in a way
a one-match smoke test misses - #1 crashes only on the SECOND match via the
omitted forgetBarLayout, on a path single-view also takes; TheSuperHackers#11 leaves a seat's
popup permanently on screen if the update-func half is missed; TheSuperHackers#9's arm side
alone leaves a pad seat unable to place anything. Full plans are in section 6.
Diplomacy.cpp had no seat concept at all - grep for 'seat' returned zero hits
in the whole file. One layout, one window, one AnimateWindowManager and one
set of per-slot widget pointers, so the popup opened at Diplomacy.wnd's
authored full-display position whoever pressed the button, and a second seat
opening it stomped the first seat's pointers. ControlBarCallback called
ToggleDiplomacy(FALSE) with no seat, unlike the GBM_MOUSE_ENTERING/LEAVING
handlers directly above it and the generals button in the same switch, both of
which already resolve the instance via ControlBarInstances::fromWindow.

All per-INSTANCE state is now indexed by seat. The NameKeyTypes are NOT - they
are derived from layout-name strings and are identical for every instance;
turning them into arrays would have been pure noise, which is exactly what a
scripted rename over that block would have produced. The bodies that walk slots
alias the arrays back to their original local names so they stay untouched.

Positioning is not enough on its own, and this is the part the handoff missed:
winSeatOwnsWindow's own comment keeps 'the quit menu, diplomacy, message boxes,
the whole shell' with seat 0, so a seat>0 could have seen a correctly-placed
popup and been unable to press a single button in it. The layout is therefore
adopted by the seat's own ControlBar, which is the only mechanism that exists
for this - the generals screen and the special-power shortcut bar are the
precedents. That buys position, per-frame re-dock, paint clipping AND click
ownership together. Added ControlBar::adoptPopupLayout() as the public entry
point (redockAfterRootsChanged is protected) and made it report the
MAX_BAR_LAYOUT_WINDOWS overflow instead of silently half-docking, since a
silent drop looks identical to the fix doing nothing.

ResetDiplomacy calls forgetBarLayout BEFORE destroyWindows for every seat. That
is mandatory, not hygiene: it runs on every match teardown including
single-view, and without it dockToRect writes through freed GameWindows every
frame - a crash that surfaces later inside winSetFont with an unrelated stack
and only reproduces on the SECOND match.

The five global winGetWindowFromId lookups are now winFindChildById scoped to
the seat's own tree (bug class 1). Default arguments keep every existing caller
compiling unchanged; seat < 0 means seat 0 for show/toggle and every seat for
hide/reset/populate, so the GameLogic-side callers stay seat-free.
…t 0's anchor

commandButtonTooltip called the GLOBAL TheControlBar->showBuildTooltipLayout,
so hovering any seat's button raised seat 0's tooltip, anchored off seat 0's
marker. It now resolves the firing bar via ControlBarInstances::fromWindow -
the mechanism WP8 built for exactly this - which falls back to TheControlBar,
so single view is the same object and the same call.

The routing fix alone is not safe, and this is the part the handoff does not
mention: ControlBarPopupDescriptionUpdateFunc is installed on EVERY instance's
layout and run per instance, but drove the global TheControlBar. Dormant only
because no seat>0 layout was ever shown - and made live by the routing change
itself. Without the companion fix, seat N's popup is evaluated against seat 0's
m_showBuildToolTipLayout and never hides, while seat 0's layout is deleted
instead. ControlBar::update now passes 'this' through runUpdate's existing
userData parameter.

Four hover/delay statics (one file static, two function statics, one more for
the offset) become per-bar members - same defect and same fix as
m_lastMoneyShown, whose comment already records that function statics made one
bar's value suppress another's.

Six global window lookups scoped: three ControlBar.wnd ids through
findBarWindowById, three ControlBarPopupDescription.wnd ids through a new
findTooltipWindowById that searches only this bar's own tooltip roots. The
first three only ever resolved via winGetWindowFromId's sibling walk, so with N
bars they matched an arbitrary bar's copy and the == test dropped into the
DEBUG_CRASH.

The BackgroundMarker anchor had a second defect independent of the lookup:
getBackgroundMarkerPos returns an AUTHORED coordinate captured once at init,
mixed against a DOCKED winGetScreenPosition - so the anchor is wrong for any
docked bar even on its own. Scaled by the bar's dock scale, the correction
W3DControlBar already carries. Exactly 1 for an undocked bar.

BEHAVIOURAL DELTA, deliberate, not byte-identical: the tooltip now prices
against getCurrentlyViewedPlayer() rather than ThePlayerList->getLocalPlayer().
Identical unless observer mode is on, where it now prices against the observed
player - which matches what line 570 of the same function already did.

NOT DONE, deliberately - the SIZE half. Registering the tooltip layout into the
bar's dockToRect pass is what would make it shrink with the viewport, but
populateBuildTooltipLayout grows the description box at runtime with raw
winSetSize/winSetPosition, and dockToRect re-applies authored geometry every
frame - in single view too. Registering without first converting those to
placeBarWindow/resizeBarWindow would collapse the popup back to its authored
102px height on the very next frame, for everyone. That pair has to land
together; logged in handoff4.

Also dropped from the finding: theAnimateWindowManager. The file-scope flag
'useAnimation' is never assigned anywhere, so  the manager is permanently nullptr and there is no slide-in to bound - a fix there would ship
nothing while still forcing a redeploy.
…mera

Completes finding TheSuperHackers#9. The earlier commit landed only the CLEAR-only sites,
because moving the arm side alone would have been a regression: arm and consume
were both pinned to a literal seat 0 by the legacy accessor family, and that
symmetry is the only reason a pad seat's build completed at all - wrongly,
through seat 0's pending placement and seat 0's cursor. Passing m_seatIndex to
the arm calls while PlaceEventTranslator still read seat 0 would have made
getPendingPlaceType() return nullptr for that seat, so seat N could arm a build
and then never place anything.

So the pair moves together here:

  arm     - the three ControlBarCommandProcessing sites (DOZER_CONSTRUCT and
            the two special-power variants) now pass m_seatIndex, which is
            already in scope and already used elsewhere in the same function.
  consume - PlaceEventTranslator, the only consumer, resolves the acting seat
            once from the message and routes every placement read and write
            through it: getPendingPlaceType, isPlacementAnchored,
            getPendingPlaceSourceObjectID, getPlacementAngle,
            getPlacementPoints and the four placeBuildAvailable clears. Every
            seat-taking overload already existed.

Third defect in the same path, not in the handoff: all three screenToTerrain
calls projected through TheTacticalView - seat 0's camera. A pad seat's pixels
were being unprojected through the wrong view, so even once the placement is
armed in the right context the building would land in the wrong world position.
They now go through the acting seat's view, falling back to TheTacticalView.

Single view is unchanged: every seat resolves to 0 and getCommandActingView()
returns TheTacticalView.
…with a debugger

Two separate environment problems, neither in any fix from this round and both
reproducing on the untouched baseline commit.

The init crash is INI::loadFileDirectory throwing INI_CANT_OPEN_FILE for
Data\INI\Weather (GameEngine.cpp:488), caught by the bare catch(...) at :788.
The file exists in retail INIZH.big and the three sibling loads succeed - the
difference is the five mod archives in the test install, whose ! prefix sorts
them first and shadows the directory listing. Excluding them produces no C++
throw at all. Same archives the TheSuperHackers#13 analysis flagged as able to override
ControlBar.wnd, biting somewhere else first.

Underneath that sits a second, still-open failure: a first-chance access
violation in W3DDisplay::getDisplayModeCount during display enumeration. An AV
is not a C++ throw so catch(...) never sees it and the process dies with no
crash record and zero stderr - which is precisely why this presented as 'crashes
at init with no information' until a debugger was attached.

Also records that CNC_GENERALS_ZH_PATH does not exist on this branch at all (an
unbounded grep returns zero hits; it is a GeneralsX-fork addition), so this
build finds data via the working directory only.
The AV in W3DDisplay::getDisplayModeCount is fully explained: dx8wrapper does
LoadLibrary("D3D8.DLL") and the process loads Windows' SysWOW64 stub rather
than the DXVK copy sitting next to the exe, so zero render devices are
enumerated and Get_Render_Device_Desc(0) returns a garbage reference.

Records what has been ruled out by measurement so it is not re-derived: the
DXVK dll is present and byte-identical to the one the working binary uses,
its dependencies resolve, vulkan-1 is present, the GPU is capable, dxvk.conf
makes no difference, mirroring the entire working run directory still
reproduces it, and SDL3 does not harden the process-wide DLL search path
(it scopes LOAD_LIBRARY_SEARCH_SYSTEM32 to combase.dll only).

That leaves it as a difference in this binary's loader behaviour versus the
GeneralsX fork's, with identical DLLs in identical directories - which is a
question for whoever runs this branch successfully today.
…rent path

StdLocalFileSystem::getFileListInDirectory passed only the LEAF name of each
subdirectory into its recursive call:

    AsciiString tempsearchstr(filenameStr.c_str());
    getFileListInDirectory(tempsearchstr, originalDirectory, ...);

so every level rebuilt the path as originalDirectory + leaf, losing the parent
entirely. A bare leaf re-resolved against originalDirectory can land back on a
directory already being walked, and the function then recurses until the stack
is gone. Caught with a debugger on a real launch: 498 stacked frames of this
one function, ending in c00000fd STACK OVERFLOW inside
std::filesystem::directory_iterator. It kills the process about ten seconds in,
AFTER DXVK has initialised and the swapchain exists, with no crash record and
no stderr - so it presents as 'the game just vanishes'.

The Win32 implementation of the identical function has always done this
correctly (Win32LocalFileSystem.cpp): it concatenates currentDirectory + name +
separator, keeping the full relative path as it descends. This makes the Std
version behave the same, with the separator matching the platform.

Found while trying to get a runtime verification pass for the splitscreen
fixes; unrelated to them, and it blocks any run whose data directory has
subdirectories.
std::filesystem::path::string() throws std::system_error when the name will
not convert to the narrow code page. StdLocalFileSystem::getFileListInDirectory
called it unguarded on every entry it walked, so a single map or file with a
non-ASCII name anywhere under the search root threw all the way out to the
catch(...) in GameEngine::init and became 'Uncaught Exception during
initialization'.

Caught with a debugger on a real launch:

  std::_Throw_system_error_from_std_win_error
  std::_Convert_wide_to_narrow
  std::filesystem::path::string
  StdLocalFileSystem::getFileListInDirectory  @ 242
  StdLocalFileSystem::getFileListInDirectory  @ 290
  FileSystem::getFileListInDirectory
  MapCache::loadMapsFromDisk                  @ MapUtil.cpp:525

so it is the MAP SCAN that trips it, which means any user with an oddly named
map cannot start the game at all - and the error names neither the file nor the
directory.

A file this engine cannot name is a file it cannot open either, so both loops
now skip such an entry and keep walking, logging the directory. This only
became reachable once the previous commit made the recursion actually descend
into subdirectories.
…s a flag

The dispatcher is 'arg += func(&argv[0]+arg, argc-arg)': num counts the flag
plus every token after it, and the return value is how many tokens to consume.
A parser that reads args[1] must therefore return 2.

parseMapName got both halves wrong:
  * 'if (num == 2)' meant the map name was only read when '-map <name>' happened
    to be the LAST two tokens on the line. Put any flag after it and -map did
    nothing whatsoever, silently.
  * 'return 1' consumed only the flag, leaving the map name itself to be matched
    against the flag table on the next iteration. Harmless while no map name
    collides with an option name, but it is matching user data against the
    option table, which is not a property to rely on.

parseFullVersion had the same return-count bug (guard was already correct).

Audited every parser in the table: these two were the only ones that read
args[1] and could return 1. The parsers that return either 1 or 2
(-splitscreendev, -xres, -yres, -replay, -jobs, -jumptoframe) are correct -
they take an OPTIONAL value and return the count they actually consumed.

Fixed in both trees since the defect is identical and is not splitscreen work.
NOTE: the Generals/ target does not build on this branch (documented in
handoff2 5.4), so that copy is unverified by compilation - the change is the
same two lines as the GeneralsMD one, which is built and verified.
Excluding the five mod archives made the INI_CANT_OPEN_FILE throw disappear,
and this document blamed them for it. That was a coincidence of load order and
the conclusion was wrong.

The actual cause was the StdLocalFileSystem recursion bug fixed in 782c609:
loadFileDirectory finds Data\INI\Weather through the recursive directory walk,
and the walk was resolving bare leaf names against the wrong parent, so it read
zero files and threw. With the recursion fixed the game loads with all five mod
archives present - verified in-game on the operator's own modded install, 8-seat
splitscreen running.

Recording this because the wrong version told the next person to strip a user's
mods, which would have been bad advice for a symptom that no longer exists.
… seat

User-confirmed TheSuperHackers#10 is NOT fixed and my diagnosis was wrong: the pad seat's
cursor stays a plain arrow with ONE unit selected AND with two or more. The
isLocallyControlled defect predicted an asymmetry (broken at 1, fine at 2+);
there is none, so something upstream is killing the hint entirely.

Prime suspect is the early return: m_isScrolling, m_isSelecting and m_mouseMode
are single-instance InGameUI members, not SeatUIContext fields, so seat 0's
state suppresses hint generation for every other seat. That was identified
during the verification sweep and deliberately logged as a separate work item
rather than fixed - which now looks like the wrong call.

GX_CURSORPROBE logs, per hint message: the acting seat, the message type,
m_isScrolling/m_isSelecting/m_mouseMode, whether the early return fired, and
inside MOUSEMODE_DEFAULT whether underWindow or the srcObj ownership test sent
it to ARROW. That distinguishes 'never runs' from 'runs and picks ARROW',
which is the fork the fix depends on.
…TheSuperHackers#8 and TheSuperHackers#10 are one bug

Real pad on a real seat, Release build, operator-driven.

TheSuperHackers#7 VERIFIED. Both halves confirmed by the operator: the pad seat's lasso draws
and it vanishes rather than freezing when seat 0 pre-empts the drag.

TheSuperHackers#10 NOT FIXED, and the diagnosis recorded here was wrong. It predicted an
asymmetry - broken with one unit selected, fine with two or more. There is no
asymmetry; the cursor is a plain arrow either way. The probe shows the
single-instance scrolling/selecting early return is NOT responsible
(scrolling=0 selecting=0), the window gate is not blocking (underWindow=0), and
the isControlledByPlayer fix genuinely works (srcOwned=1) - but mousedOver=0 on
every sample, so there is never a hovered drawable to decide a shape from.

TheSuperHackers#8 REPRODUCED with the probe, and handoff3's stated cause is refuted by
measurement: isPoint=1 and the acting seat resolves correctly, willSelect=0
means the region yielded zero drawables, and no [GXPICK] line shows a window
blocking the pad seat - the only refusals belong to seat 0.

Both are the same defect: SelectionXlat builds the mouseover hint from
pickDrawable, so an empty pick gives no hover (no cursor shape) and no
selection. Leading hypothesis is a coordinate-space mismatch between the
display-absolute click region and the view-relative pick ray; that is the first
thing to establish, not to assume.
Records where the branch stands, what runs, and the one unanswered question
that decides whether TheSuperHackers#10 is fixed or still broken.

Nothing is pushed anywhere - no remote, no fork, no PR. The Windows box is kept
in sync by a git bundle over scp.
…e hint said

TheSuperHackers#10 again, and the previous diagnosis was wrong in an instructive way. It kept
asking which gate in the hint chain killed the cursor. None of them did. The
shape decision was always arriving correctly and the RENDERER could not draw it.

cursorTextureFileName picked the filename off info->numFrames: <=1 meant the
unnumbered form, otherwise <name>%04d.tga. numFrames is ALWAYS 1, because retail
Mouse.ini declares no frame count for any cursor - an unbounded grep for `Frames`
over the shipped Data\INI\Mouse.ini returns zero hits, the only anim key present
being `Directions = 8` on Scroll. So this always asked for the unnumbered name.

The shipped art does not agree. Scanning every .big in a retail ZH install:

    Art\Textures\sccmove0000.dds .. sccmove0020.dds   21 frames, NO plain file
    Art\Textures\sccscroll0000.dds ..                  4 frames, NO plain file
    Art\Textures\sccpointer.dds                        plain, exists
    Art\Textures\sccattack.dds                         plain AND numbered

so the lookup for SCCMove missed, WW3D returned its 128x128 missing-texture
placeholder, the size guard correctly refused it as not cursor-shaped, and
drawSeatCursor substituted ARROW. Move and Scroll could not render for any seat,
ever, regardless of what createCommandHint decided.

That is also why the only shapes ever seen on a pad seat were the arrow and the
attack cursor: SCCPointer and SCCAttack are the two that ship unnumbered. The
"cursor works on enemies but not on my own units" report was never about picking
at all - Select has no texture art whatsoever, only a .ani.

Fix: ask the art rather than the INI. Measure the unnumbered file, take it if it
measures cursor-shaped, else use the numbered one; resolve once per cursor state
and cache. Left UNRESOLVED rather than latching a guess when neither measures,
because cursor textures load on demand and the first frames a seat cursor is
drawn can measure nothing - and re-point the cached Image when it does resolve,
or it keeps drawing from the name we just established was wrong.

Player 1 is untouched: it runs RM_WINDOWS and draws the OS .ani cursors from
Data\Cursors, never these textures.

Still open, logged not fixed: 27 of 37 cursor states have no texture art at all
(AttackMove, Select, Enter, Waypoint, ...) and exist only as .ani and .W3D. A
pad seat still falls back to the arrow for those. That needs a different art
source and is its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… visibility, not the acting seat's

Instrumentation, not a fix. Default OFF; set GX_PICKALL=1.

"Click does not select, drag does" has exactly two candidate gates, because both
go through iterateDrawablesInRegion from the same call site with the same struct
and differ only in branch. The rect branch walks TheGameClient->firstDrawable()
and projects each. The point branch delegates to pickDrawable, which adds (a) the
window gate - already cleared by measurement, underWindow=0 - and (b)
castRay(raytest, testAll=false, pickType).

(b) filters on Is_Really_Visible(). That flag is pure RENDER RESIDUE:

  W3DScene.cpp:534-655   Visibility_Check rewrites it for every render object
                         from THIS view's camera frustum and THIS view's
                         player's vision (seatOwnerFilterHidesObject)
  W3DScene.cpp:1466      Visibility_Checked is cleared straight after, so the
                         pass genuinely re-runs per view
  Display.cpp:163        drawViews walks the view list head to tail
  Display.cpp:109        attachView PREPENDS

so seat 0's view - attached first at InGameUI.cpp:1442, the only other
attachView being the seat>0 one - sits at the tail and is drawn LAST, and seat
0's visibility set is the one standing when the message stream is translated
(GameClient.cpp:820 draws, GameEngine.cpp:929-930 then propagates).

A pad seat's point pick is therefore answered against what SEAT 0 can see. Its
own units, framed by its own camera somewhere seat 0 is not looking, are
frustum-culled or shrouded away and the ray never tests them. The drag path
never reads the flag. That is the reported asymmetry exactly, and it predicts
seat 0 keeps working - which it does.

Not yet proven, hence a probe rather than a fix. GX_PICKALL=1 bypasses the
filter: if a pad seat's click-select starts working with it set, the diagnosis
is confirmed; if it does not, the cause is elsewhere and this should be reverted
rather than built on. Bypassing is NOT the fix either way - it would let a seat
pick units hidden in its own fog. The real fix is to evaluate visibility for the
PICKING seat instead of reusing the last render pass's answer.

Also retracts a load-bearing piece of handoff4's evidence: "mousedOver=0 on every
sample" is not diagnostic. InGameUI.cpp:3064 sets m_mousedOverDrawableID to
INVALID in the terrain branch, so 0 is the CORRECT value whenever the cursor is
over ground - which is what was being sampled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to the seat cursor fix. cursorUsesNumberedArt left s_naming UNRESOLVED whenever
neither the unnumbered nor the numbered texture measured, so the 27 retail cursor states that
have no texture art in either convention - they exist only as Data\Cursors\*.ani and
Art\W3D\*.W3D - re-measured two absent textures on every seat on every frame, forever.

Adds an ABSENT state so a conclusive miss is latched. The wrinkle is that Get_Texture returns
its placeholder both for "file does not exist" and for "archives not mounted yet", and those are
indistinguishable at this level, so ABSENT is only latched after a bounded number of attempts -
one second of frames, far more than a mounted archive needs. Behaviour is unchanged: ABSENT
reports not-numbered, the size guard rejects the placeholder and drawSeatCursor falls back to
the arrow, exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… not its own

Fixes TheSuperHackers#8, and TheSuperHackers#10's remaining half with it. Confirmed by A/B on a real pad, not argued.

W3DView::pickDrawable casts through RTS3DScene::castRay with testAll=false, which considers
only render objects flagged Is_Really_Visible(). That flag is RENDER RESIDUE. Visibility_Check
rewrites it for every object in the scene once per VIEW per frame, from that view's camera
frustum and that view's player's vision. Display::drawViews walks the view list head to tail and
Display::attachView PREPENDS, so seat 0's view - attached first at InGameUI.cpp:1442 - sits at
the tail and is drawn LAST. Its answer is the one still standing when the message stream is
translated next frame.

So every seat's point-pick was answered with SEAT 0's vision. A pad seat's own units, framed by
its own camera somewhere seat 0 was not looking, were frustum-culled or shrouded away and the
ray never tested them. Drag-select kept working because iterateDrawablesInRegion's rect branch
walks TheGameClient->firstDrawable() and never reads the flag - which is exactly why the bug
presented as "drag selects, click does not", for seats > 0 only.

It explains the cursor too, so TheSuperHackers#8 and TheSuperHackers#10 really were one defect - handoff4 was right about
that and wrong about the mechanism. createCommandHint takes `draw` from the pick, so an empty
pick leaves drawSelectable FALSE and the MSG_DO_MOVETO_HINT arm falls through to MOVETO. That is
the move cursor appearing over your own units instead of SELECTING.

Fix: evaluate the predicate live for the picking view instead of reading the leftover bit.
objectVisibleToView is Visibility_Check's own per-object decision - force-visible, hidden,
frustum cull, seat owner filter, effectively-hidden/shroud-obscured - in the same order, so the
two cannot drift. castRay takes an optional view camera and player; passing them switches it
from residue to live evaluation.

Bypassing the filter (testAll=TRUE) is NOT the fix even though it made the symptom go away in
the probe run: it would let a seat pick units hidden in its own fog.

Single view is byte-identical: the call site only supplies a camera when getBoundSeatCount() > 1,
so a one-seat game takes the original branch unchanged.

The Generals tree gets the same signature so the shared Core W3DView has one spelling to call.
It has no splitscreen and no owner filter, so a supplied camera there only swaps the residue for
a live frustum test, and viewPlayerIndex is unused.

Supersedes the GX_PICKALL probe from 0a17bb6, which has served its purpose and is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… other seat saw a ghost

Reported live: on a pad seat, click a structure in the control bar, click again to place, nothing
happens - and the building preview never appears at all.

The arm side was already correct. ControlBarCommandProcessing passes m_seatIndex, so
placeBuildAvailable sets ctx.m_pendingPlaceType for the right seat, creates the preview drawable
and tags it to that seat's player. Nothing was wrong there, which is why this survived the round
that fixed TheSuperHackers#9's arm/consume pair.

What was missing is the PER-FRAME UPDATE. handleBuildPlacements resolves everything through
m_activeSeat, and it is called from InGameUI::update - not from message translation. m_activeSeat
is only non-zero while a seat's message is being translated; InGameUI.h says so directly:
"Render/HUD code always runs with m_activeSeat == 0". So the update serviced seat 0 and nothing
else. A pad seat's ghost was created and then never moved to that seat's cursor, never
legality-checked, never tinted - so it sat wherever it was born and the placement could not be
completed.

Two more seat-0 assumptions inside the same function would have kept it broken even once the
right context was reached:

* the cursor position came from TheMouse->getMouseStatus(), the OS pointer. A pad seat has none,
  so every seat's ghost would have tracked seat 0's mouse.
* all five screenToTerrain calls went through TheTacticalView, seat 0's camera, so a seat's pixels
  projected to the wrong world position - a building placed somewhere other than where it was
  aimed.

Fix: loop the seats that actually have a placement pending and scope m_activeSeat around the body,
which is the same mechanism MessageStream already uses. That is what keeps the change small -
every legacy accessor inside (isPlacementAnchored, getPlacementPoints,
getPendingPlaceSourceObjectID, the m_seatContexts lookups) then answers for the right seat with no
further edit. The cursor now comes from getSeatHoverPixel, which returns TheMouse for seat 0
unchanged, and the projections go through that seat's own View.

Single view is unchanged: the loop finds seat 0 alone, setActiveSeat(0) is what it already was,
getSeatHoverPixel(0) is TheMouse, and viewForSeat(0) is TheTacticalView.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wh1ter0se69 and others added 12 commits August 6, 2026 15:35
…greed

Two places decide whether a fogged building's stand-in belongs in a viewport, and they said
different things about the same object.

seatOwnerFilterHidesObject - the one Visibility_Check consults - is deliberately lenient
(W3DScene.cpp:524-532). The scene holds ONE snapshot per object, whichever seat fogged it last,
but every seat that has fogged it recorded its own and they all depict the same building in the
same place. So a viewport whose player also remembers the object is shown the stand-in; only a
player with no memory of it at all is shown nothing. Its comment says exactly that.

renderSingleDrawable then tested only "does the scene copy belong to me" and returned early
otherwise, which puts back the hole that rule exists to avoid: a seat that had scouted a civilian
building saw nothing there as soon as another local seat was the more recent one to fog it.

The disagreement was pre-existing - Visibility_Check has always used the lenient form - but it
was one-sided and mostly invisible. It became reachable from the other side in 66c286d, which
made the pick answer per-seat: a seat could then CLICK a ghost building its own viewport was
refusing to draw.

Fix: the draw gate now asks the same question as the filter. Single view is unaffected - with one
seat there is only ever one snapshot owner, so the added term cannot change the answer.

This is a coherence fix, not a claim to have closed TheSuperHackers#12. TheSuperHackers#12 has a second, independent
sub-mechanism - snapShot displaces the real render object out of RenderList under an ownsScene
guard that restoreParentObject has no counterpart for, so a seat meeting a displaced building for
the first time has neither a snapshot nor a FOGGED->clear edge to trigger a restore, and no
visibility predicate can reach an object that is not in the list at all. That one still needs a
probe run to confirm it is the live case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…print

Regression from 478b07d, reported live: with a placement armed on the pad seat, seat 0's
placement square stopped drawing - "there is no more square that draws; I had to build or move to
make it appear again".

removeAllBibs() is GLOBAL - it clears every seat's footprint decal at once - and 478b07d left it
inside the body that now runs once per seat. So on each odd frame seat 0's pass added its bib and
seat 1's pass immediately removed it again, permanently, for as long as any other seat had a
placement armed. Only the last seat in the loop kept a footprint.

Hoisted the clear into the caller, on the same odd-frame cadence the per-seat legality check uses
so the two stay in step. Each seat then adds its own bib after the single global clear.

Single view is unchanged: with one seat the clear happens once per odd frame and one seat adds its
bib, exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d seat could never build

Three reported symptoms, one cause. Operator, on a pad seat: the building ghost is red everywhere
so it can never be placed; the same press that should place it also orders the dozer to walk
there; and the ghost only appears while the button is held.

Every no-arg overload in the placement accessor family forwards to a LITERAL 0
(InGameUI.cpp:3848, :3862, :3879, :3906, :3924, :3941, :3961) - not m_activeSeat, not the acting
seat. Both the message translator and the per-frame ghost updater are seat-scoped and call them.

RED EVERYWHERE. handleBuildPlacementsForActiveSeat read getPendingPlaceSourceObjectID() no-arg, so
seat 1 got seat 0's builder - INVALID_ID whenever seat 0 has nothing armed. BuildAssistant.cpp:936
then leaves playerIndex at -1, and PartitionManager.cpp:3258 returns CELLSHROUD_SHROUDED for a
negative player before it looks at a cell at all, so isLocationLegalToBuild returns LBC_SHROUD at
every position on the map. InGameUI.cpp:1885 is the engine's only IllegalBuildColor tint, so the
ghost is red by construction, everywhere, forever. The DEBUG_ASSERTCRASH guarding that case is
compiled to (void)0 in this build.

CLICK ALSO MOVES. PlaceEventTranslator reads through placeSeat everywhere (:90, :91, :103, :177,
:190, :195, :338, :348) but four WRITES had no seat: :115, :154, :317, :356. So a pad press armed
seat 0's anchor, seat 1's own isPlacementAnchored(placeSeat) at :177 stayed FALSE, the commit
block was skipped, and disp stayed at its KEEP_MESSAGE initialiser - the click fell through to the
command translator, which issued the move. Translator order is Place 30 before Command 70
(GameClient.cpp:294-303), so "placement runs too late" is refuted: it saw the click first and
declined it.

GHOST ONLY WHILE HELD. Same latch from the other side: once a pad press sets seat 0's anchor,
isPlacementAnchored() reads TRUE for every seat, so the ghost position comes from seat 0's stale
anchor pixel instead of the correct getSeatHoverPixel(m_activeSeat) branch. That is also why the
stick did not move the ghost but the button did.

Fixed by routing to the seat the code is already scoped to: six reads in
handleBuildPlacementsForActiveSeat take m_activeSeat, four writes in PlaceEventTranslator take
placeSeat.

Three further no-arg calls in seat-scoped translators went with them, one of which is a real
cross-seat bug in its own right: SelectionXlat.cpp:1044 tested seat 0's placement and :1046 called
the 2-arg placeBuildAvailable, so a PAD seat's right-click cancelled PLAYER 1's building
placement. Also SelectionXlat.cpp:983 and WindowXlat.cpp:262.

Left alone deliberately: W3DInGameUI.cpp:687 reads isPlacementAnchored() from RENDER code, where
m_activeSeat is always 0. It drives m_buildingPlacementAnchor/m_buildingPlacementArrow, which are
single shared render objects - making the line-build drag arrow per-seat is a separate change, not
a one-line seat argument.

Single view is unchanged throughout: with one seat every argument added here is 0, which is
exactly what the no-arg overloads were passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The on-screen SEATCURSOR line latched to the first seat drawn each frame, which is always seat 0,
so it could not answer the only question now outstanding about cursors: what shape is a PAD seat
asking for, and what art does that resolve to.

The latch was itself a fix for the opposite failure - the LAST seat used to overwrite the line, so
a perfectly healthy "seat7 SCCPointer.tga 32x32" stood in for seat 0's broken cursor. One line per
seat answers both without either hiding the other, and mirrors the control bar report that already
works this way.

This matters before any work on the missing cursor art. 27 of the 37 cursor states ship no texture
at all - only Data\Cursors\*.ani and Art\W3D\*.W3D - and making them renderable is roughly a day.
That is only worth spending if the hint logic actually selects those states for a pad seat. If a
pad seat's cursorType never leaves ARROW, the art is not the bug and decoding it buys nothing.
This probe is what tells the two apart, and it costs nothing to run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ip no texture

The operator, comparing against stock Generals: "there is no proper icons over buildings, it
should be this, and over units too waypoint, and over supplies/garrison the three green animated
arrow should show ... you can not tell if something works unless you see it works."

Retail ships texture art for only 8 of the 37 cursor states - SCCPointer, SCCAttack and SCCRepair
unnumbered, plus SCCMove and SCCScroll as numbered frames. The other 27 (Select, EnterFriendly,
Waypoint, Dock, SetRallyPoint, ResumeConstruction, CaptureBuilding, ...) exist ONLY as
Data\Cursors\*.ani and Art\W3D\*.W3D. Seat 0 is unaffected: it runs RM_WINDOWS and hands the .ani
straight to the window manager. A seat cursor is drawn by us through TheDisplay->drawImage, had
nothing to draw, and silently fell back to the arrow - so a pad seat showed the same shape for
garrison, waypoint, dock, select and invalid alike. That is also why it was hard to confirm any
other fix: the cursor is the feedback channel.

This is not a new decoder. SDL3CursorManager::initResources already loads every one of those .ani
files at startup and IMG_LoadAnimation_IO already decodes them to RGBA; loadANI then threw the
surfaces away at IMG_FreeAnimation the moment SDL had built an opaque SDL_Cursor from them.
AnimatedCursor now retains a tightly-packed ARGB8888 copy plus the hotspot, and the seat renderer
uploads one frame to a TextureClass on first use, wrapping it as an IMAGE_STATUS_RAW_TEXTURE Image
the same way W3DRadar builds its per-player radar images. Nothing is read from or written to disk
beyond what the engine already loads - the art is the player's own installed game data, decoded in
memory - so no retail art is redistributed and the repo stays publishable.

Order matters and is deliberate: mapped image, then texture, then .ani, then the arrow. Preferring
.ani would take MOVE and SCROLL off their 32x32 DXT art and onto 4bpp indexed frames, undoing
6afadc1.

Animation is driven by the .ani's OWN frame count, not CursorInfo::numFrames. Retail Mouse.ini
declares no frame count for any cursor - unbounded grep, zero hits - so numFrames is always 1 and
using it here would freeze every animated cursor on frame 0, including the three green arrows the
operator specifically asked for. Clamped to MAX_2D_CURSOR_ANIM_FRAMES.

Single view is unaffected: seat 0 draws its cursor through the OS, not through this renderer.

Not yet runtime-verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… ever

Finding TheSuperHackers#12, deferred since 2026-08-01 and confirmed still live today: a bunker visible in one
seat's viewport and absent from the other's, standing right next to it. Same with oil derricks.

The scene displacement is one-sided. W3DGhostObject::snapShot takes the REAL render object out of
the shared scene - robj->Remove() at :463 - under an ownsScene guard meaning "I am the last local
seat to lose sight of this". That part is right: the scene is shared, so one seat fogging a
building must not blank it while another seat is looking at it.

But every path that puts it back runs through freeSnapShot, and freeSnapShot needs two things that
a seat meeting the object for the first time does not have. Its whole body is inside
`if (m_parentSnapshots[playerIndex])`, and both callers in PartitionManager gate on
`m_shroudednessPrevious[playerIndex] == OBJECTSHROUD_FOGGED`. A seat that has never seen the
building has no snapshot, and goes straight from SHROUDED to CLEAR without ever being FOGGED. So
neither gate opens, restoreParentObject never runs, and the real object stays out of RenderList -
invisible to that seat permanently, at any range.

Neutral IMMOBILE structures are the visible case because they are exactly what gets ghosted:
PartitionData::getShroudedStatus forces anything neutral and mobile down to SHROUDED instead.

Adds GhostObject::restoreIfDisplacedFor(playerIndex) - a no-op on the base, overridden in
W3DGhostObject - called from the CLEAR and PARTIAL_CLEAR branches when the FOGGED precondition does
NOT hold. It restores only when something is actually displacing the object
(m_sceneSnapshotPlayer >= 0) and only for a local seat, then mirrors the restore already inside
freeSnapShot: clear every seat's snapshot out of the scene, forget the owner, put the real object
back.

This is the second and last of the two mechanisms behind TheSuperHackers#12. The first was the draw gate
disagreeing with the visibility filter, fixed in 67801c8; that one could not reach this case,
because no visibility predicate can help an object that is not in the render list at all.

Sim-safe: this only moves render objects in and out of the shared W3D scene. It reads shroud state
that PartitionManager has already computed and writes none of it, and GhostObject's xfer chain is
untouched.

Single view is unchanged: with one seat, ownsScene is true exactly when that seat fogs the object
and freeSnapShot's own FOGGED->clear edge always fires on the way back, so the new branch finds
m_sceneSnapshotPlayer < 0 and returns immediately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rHackers#10 and TheSuperHackers#12 fixed; four verified on a pad

Eleven commits. The previous dropoff's single 'decisive' question turned out to be decisive about
the cursor RENDERER rather than the hint logic, and three pieces of handoff4's evidence are
retracted here: the enemies-vs-own-units report, mousedOver=0, and the coordinate-space hypothesis.

Records what is runtime-verified (click-select, placement, cursors) versus merely compiled (six of
mirelle's thirteen have still never been run), why TheSuperHackers#11's size half is a considered NO-GO rather than
an oversight, and the pad-vs-pad test plan the second controller unlocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported live, and visible on seat 0 as well as a pad seat: the control bar shows
"Building:\n%.0f%%" verbatim while the world-space text over the structure shows the correct
"Building: 51%".

updateConstructionTextDisplay does format the string correctly. The defect is which window it
writes to: winGetWindowFromId(nullptr, descID) is a GLOBAL name lookup, and with more than one
ControlBar instance it reaches whichever copy the name resolves to - the newest head-inserted one.
Every other bar therefore never had its description text written and kept the placeholder authored
in ControlBar.wnd, which is literally the format string. That is why the symptom is an
UNFORMATTED string rather than a stale or wrong number: nothing wrote to that window at all. It is
also why seat 0 shows it too - seat 0's bar is not the newest instance either.

Routed through findBarWindowById, which scopes strictly to this instance. No-op in single view,
where the global lookup and the scoped one resolve to the same window.

Note for whoever picks this up: this is bug class 1 and it is NOT isolated. An unbounded grep
finds 22 remaining winGetWindowFromId(nullptr, ...) lookups under
GameClient/GUI/ControlBar/. Each is a latent instance of exactly this, and any of them that writes
rather than reads will present the same way - a control put in one bar and never in the others.
They should be swept deliberately, with a build between, rather than in one blind pass.

Also records that the TheSuperHackers#12 "still invisible" observation from the same session is INCONCLUSIVE:
both pads were dropping in and out at the time, so nothing seen on screen can be attributed to the
shroud code. Re-test on wired pads before concluding anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…artwork pass, two new findings

VERIFIED: TheSuperHackers#13 (main-menu resolution change leaves the shell map clean), TheSuperHackers#4's artwork half, and the
build-progress text fix. TheSuperHackers#10 comprehensively confirmed - one frame showed three seats drawing three
DIFFERENT cursor states at once, covering the plain texture, the .ani decode and the numbered-frame
art simultaneously.

NEW: TheSuperHackers#11's position half is not fixed despite 7c91c33 - seat 0's tooltip anchors to seat 2's bar.
The lookups are correctly scoped; the anchor math applies a RUNNING DELTA against
m_tooltipLastOffset instead of an absolute, which cannot be right across bars at different dock
offsets.

NEW: seat 2 renders terrain lit where its own radar shows it unexplored. The per-view render player
is set correctly, so it is the shared destination shroud texture and the draw ordering it depends
on - seat 2 is drawn first, so it is exactly the view that would show a later upload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ckers#3 half passes; splash misposition narrowed to two candidates

TheSuperHackers#4 VERIFIED in full: three seats showed three faction schemes, and on seat 0's defeat only seat 0's
bar went observer while seats 1 and 2 kept theirs. The empty-looking bar in the first screenshot was
simply nothing selected.

TheSuperHackers#2/TheSuperHackers#3 half passes. The trigger fires and there is no global input freeze - seats 1 and 2 played on,
which was the sharper claim. But the splash is centred on the whole display instead of the seat's
viewport, and the operator reports money and team overlays doing the same, so it is broader than
the finding.

Three hypotheses were tried and all three are refuted in the doc, with the two survivors named:
isSplitscreenEnabled() false at creation time, or info.windows empty so the transform loop iterates
nothing. Both fall out of one probe. Stopping there rather than guessing a fourth time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…command another army

The generals promotion screen is positioned CORRECTLY per seat while the defeat splash, score
panel and pause menu all go full-display. That makes this one job rather than four bugs: the
generals screen and the special-power shortcut bar register their layout with the seat's own
ControlBar, which is the established mechanism. Port the others onto it instead of hand-rolling
scale/offset maths per overlay - showOutcomeSplashForSeat already tried that and is the one that
does not work. Likely covers #1 as well.

Also records that after seat 0 was defeated its keyboard began commanding player 3's units while
seat 2 was still playing that army. Mechanism located: the observer cycle-player paths call
rts::changeLocalPlayer(), which reassigns the local player outright, and getCommandActingPlayer()
falls back to exactly that. Whether it is pre-existing retail observer behaviour or a splitscreen
regression is NOT established - A/B the baseline before fixing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…orm instead of guessing at it again

The end-of-match splash is centred on the whole display instead of the seat's viewport. FIVE static
hypotheses have now been refuted, all by reading:

* seat 0 is not on a legacy path - ScriptActions.cpp:217/220/241/244/263 all call
  showOutcomeSplashForSeat(0, ...), the same entry seats 1..7 use.
* seat 0 has a view - InGameUI.cpp sets s0->m_view = TheTacticalView.
* the full-display size guard cannot bail - seat 0's viewport is 960x540 of 1920x1080.
* isSplitscreenEnabled() is true - CommandLine.cpp:797 sets m_splitscreenEnabled = TRUE in
  parseSplitscreenDev, and GameEngine.cpp:608 forwards it to the seat manager.
* info.windows is populated - winCreateFromScript ends with `if(info) *info = scriptInfo;` after
  pushing every parsed root.

Every gate that could skip the transform is open, and the transform itself looks correct. That is
the point to stop reasoning and measure, so this logs every gate, the view/display geometry, and
for each root the before, the values written, and a READ BACK of what the window manager actually
stored. If the readback matches what was set, the transform worked and something re-applies
authored geometry afterwards - which makes it an ordering problem, not a maths one, and points
somewhere entirely different.

Instrumentation only, default OFF, GX_SPLASHPROBE=1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant