fix #382: chat-window indicator buttons invisible until first hovered

Root cause (found via reference-identity-verified live-DAT probing, not
a guess): the four main-chat-window indicator buttons (0x10000522-
0x10000525) resolve their own correct ActiveState="Normal" at
construction, then get blanked to "" moments later in the SAME
LayoutImporter.Build call. The indicator column's backing panel
(0x10000600) authors PassToChildren=true on its own empty DirectState
(confirmed live: States[0xFFFFFFFF].PassToChildren == true); when
LayoutImporter.BuildWidget's post-attach state reapply runs for that
panel, UiDatElement.TrySetRetailState cascades its DirectStateId to
every IUiDatStateful child, including the already-correctly-resolved
buttons. UiButton.TrySetRetailState's DirectStateId branch used to
accept that cascade because every button structurally carries a
DirectStateId entry in its States dict as a property bag (ToggleBehavior/
RolloverEnabled/etc), independent of whether it authors any blank
sprite, so TryFindState(DirectStateId) found that entry and blanked
ActiveState even with no "" media. A hover "fixed" it only because
UiButtonStateMachine.RequestedState resolves to the same canonical
Normal id regardless of PointerOver when RolloverEnabled is false.

Retail's own decompiled UIElement::SetState @0x00464e70 does the exact
same unconditional-commit-plus-cascade; retail avoids this specific bug
purely through construction timing (UIElement::Initialize's SetState
call precedes child-tree construction, so a cascade fired during import
always iterates zero children). Our port's LayoutImporter.BuildWidget
deliberately reapplies in the opposite order to give retained
PassToChildren tabs their authored child media, so this literal
state-machine port needed a compensating guard.

Fix: UiButton.TrySetRetailState's DirectStateId branch now requires
REAL "" media (HasStateMedia("")) before accepting the transition.
Scoped to UiButton only; UiDatElement's parallel branch and the cascade
mechanism are unchanged, so CharacterStatController's own
PassToChildren-driven chrome children are unaffected. Register row
AP-206 records the divergence from retail's literal unconditional-
commit semantics. Regressed by two fast unit tests in UiButtonTests.cs
(DirectStateCascade_WithoutRealMedia_DoesNotBlankAnAlreadyResolvedState,
DirectStateTransition_WithRealMedia_StillSucceeds) plus a live-mount
probe confirming all four buttons resolve ActiveState="Normal"
immediately after import against the real installed DAT.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 23:03:31 +02:00
parent a31fd631ad
commit d1c60df946
6 changed files with 292 additions and 10 deletions

View file

@ -26,15 +26,84 @@ What does NOT go here:
## #382 — Floating chat-window tab buttons are invisible until first hovered
**Status:** OPEN — filed 2026-08-11 at Campaign OP gate 4 (user report with
screenshots). The side chat windows' numbered tab buttons render NOTHING in
their resting state — only once the pointer hovers one does it paint its
correct orange numbered-button art (the user's third screenshot shows the
correct post-hover look). So the un-hovered/Normal state's media is not
being drawn (or the button starts in a state with no StateDesc media) while
the Highlight state works. Investigate the CH6 chat-window tab buttons'
state/sprite wiring at the live mount — evidence first (which state id the
button starts in, what media that state resolves) — before fixing.
**Status:** ROOT-CAUSED + FIXED (this commit) — pending the user's re-gate.
Filed 2026-08-11 at Campaign OP gate 4 (user report: nothing renders at
rest on the four main-chat-window indicator buttons — `0x10000522`-
`0x10000525`, `ChatWindowController.Indicator1Id`-`Indicator4Id` — hover
reveals the correct orange art).
**A prior session's extensive static trace found no bug and left a
live-mount probe for this session** (see the earlier revision of this
entry, preserved in git history). Running that probe with reference-
identity verification (`RuntimeHelpers.GetHashCode` + `ReferenceEquals`
against a build-time-captured instance, not just re-reading a possibly-
different widget) found the actual defect: the SAME `UiButton` instance
resolves `ActiveState="Normal"` correctly at its own construction, then
gets blanked to `""` moments later — still inside the SAME
`LayoutImporter.Build` call, before the probe ever reads it.
**ROOT CAUSE.** The indicator column's backing panel (`0x10000600`)
authors `PassToChildren=true` on its OWN empty DirectState (confirmed
live: `States[0xFFFFFFFF].PassToChildren == true` — almost certainly
intended to route the panel's OTHER named states, HideDetail/ShowDetail,
to an unrelated sibling, not Normal/Highlight to these buttons).
`LayoutImporter.BuildWidget` reapplies every widget's own default state
AFTER its children are attached (so retained PassToChildren TABS get
their authored Open/Closed child media — see that method's own comment);
when the PANEL's reapply runs, `UiDatElement.TrySetRetailState` cascades
its DirectStateId to every `IUiDatStateful` child, including the four
ALREADY-correctly-resolved buttons. `UiButton.TrySetRetailState`'s
DirectStateId branch used to accept that cascade because
`_mediaInfo.States` structurally carries a DirectStateId entry on EVERY
button (it is the property bag for ToggleBehavior/RolloverEnabled/etc,
independent of whether the button authors any blank sprite — see
`UiButtonTests.AddBoolProperty`), so `TryFindState(DirectStateId)` found
that entry and blanked `ActiveState` even though `StateMedia` has no `""`
key at all. A synthetic hover "fixed" it only because
`UiButtonStateMachine.RequestedState` resolves to the SAME canonical
Normal id regardless of `PointerOver` when `RolloverEnabled` is false, so
the next `UpdateVisualState()` call (from the hover event) re-picks
"Normal" from `_availableStates` — the blanking was a one-shot
construction-time event, not a persistent state.
Retail's own decompiled `UIElement::SetState @0x00464e70` does the exact
same unconditional-commit-plus-blind-cascade (`ElementDesc::AccessStateDesc`
finding ANY StateDesc, media or not, is enough to commit `m_curStateDesc`/
`m_state` and cascade to every child when `PassToChildren` is set).
Retail avoids this specific bug purely through construction TIMING:
`UIElement::Initialize`'s `SetState(m_defaultState)` call is literally the
second operation in the function, before any child-tree construction —
so a PassToChildren cascade fired during import always iterates ZERO
children in retail. Our port's `LayoutImporter.BuildWidget` deliberately
reapplies in the opposite order (children built first, then the parent's
default is reapplied and cascades DOWN into the now-existing children),
which is what makes this literal state-machine port hit a case retail's
own timing never exercises.
**Fix:** `UiButton.TrySetRetailState`'s DirectStateId branch now requires
REAL `""` media (`HasStateMedia("")`) before accepting the transition — a
structurally-present-but-media-less States entry no longer counts. Scoped
to `UiButton` only; `UiDatElement.TrySetRetailState`'s parallel branch and
the cascade mechanism itself are unchanged, so `CharacterStatController`'s
own three-chrome-children PassToChildren cascade (which depends on the
SAME reapply ordering) is unaffected. Register row AP-206 records the
divergence from retail's literal unconditional-commit semantics.
Regressed by two new fast unit tests in
`tests/AcDream.App.Tests/UI/UiButtonTests.cs`
(`DirectStateCascade_WithoutRealMedia_DoesNotBlankAnAlreadyResolvedState`,
`DirectStateTransition_WithRealMedia_StillSucceeds` — the companion
positive case, confirming an AUTHORED blank DirectState can still be
entered explicitly) plus a rewritten, now-asserting live-mount probe
(`ChatIndicatorButtonLiveMountProbeTests.
IndicatorButtons_ResolveNormalStateAtRest_ThroughTheLiveImportPath`,
`ACDREAM_PROBE_LIVE_MOUNT=1`) that confirms the fix against the real
installed DAT: all four buttons now resolve `ActiveState="Normal"`
immediately after import, with no hover required.
**Re-gate:** the four floating-window indicator buttons (mail/chat-tab
style LEDs at the top-left of the main chat window) should show their
correct orange numbered art immediately on window open, with no hover
needed.
## #381 — Options-panel footer (Apply/Reset/Defaults) needs an opaque backing field; list content shows through between the buttons

View file

@ -184,6 +184,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| AP-206 | **Filed 2026-08-11 at Campaign OP gate 4 (#382).** `UiButton.TrySetRetailState`'s DirectStateId branch now requires REAL `""`-keyed media (`HasStateMedia("")`) before accepting a DirectState transition; a `_mediaInfo.States` entry that exists ONLY as a property bag (every button carries one, holding ToggleBehavior/RolloverEnabled/etc regardless of whether it authors blank media) no longer counts. A reference-identity-verified live-DAT probe found the chat window's four floating-window indicator buttons (`0x10000522`-`0x10000525`) resolve their own correct `ActiveState="Normal"` at construction, then get blanked to `""` moments later in the SAME `LayoutImporter.Build` call: the indicator column's backing panel (`0x10000600`) authors `PassToChildren=true` on its own empty DirectState (confirmed live: `States[0xFFFFFFFF].PassToChildren == true`), and `LayoutImporter.BuildWidget`'s post-attach state reapply (needed so retained PassToChildren TABS get their authored Open/Closed child media) cascades that DirectState to every `IUiDatStateful` child — including these already-correctly-resolved buttons. Retail's own decompiled `UIElement::SetState @0x00464e70` commits its `m_curStateDesc`/`m_state` unconditionally once `ElementDesc::AccessStateDesc` finds ANY StateDesc (media or not) and does the exact same blind per-child cascade; retail avoids this exact bug purely through construction TIMING — `UIElement::Initialize`'s `SetState(m_defaultState)` call is the SECOND operation in the function, before any child-tree construction, so a PassToChildren cascade fired during import always iterates zero children in retail. Our port's `LayoutImporter.BuildWidget` deliberately reapplies AFTER children are attached (the opposite order), so this literal 1:1 state-machine port needed a compensating guard rather than a full reapply-ordering rewrite (out of scope for this fix; `CharacterStatController`'s own three-chrome-children PassToChildren cascade depends on the current ordering and is left untouched). | `src/AcDream.App/UI/UiButton.cs` (`TrySetRetailState`'s `stateId == UiStateInfo.DirectStateId` branch) | Scoped to `UiButton` only — `UiDatElement.TrySetRetailState`'s parallel DirectStateId branch (and the cascade mechanism itself) are UNCHANGED, so every existing PassToChildren consumer keeps its current behavior; the fix only stops an UNRELATED ancestor's cascade from overriding a button's OWN already-resolved, independently authored state with an empty one it never asked for. | If a future button is EVER meant to render literally blank at rest via a cascaded DirectState with no authored `""` media, this guard would reject that transition (falls back to its previous `ActiveState`) — no such button is known to exist today; `UiButtonTests.DirectStateTransition_WithRealMedia_StillSucceeds` documents that an AUTHORED blank state still works. | `UIElement::SetState @0x00464e70` (cascade + unconditional commit); `UIElement::Initialize @0x00462c90` (SetState call precedes child construction) — both in `docs/research/named-retail/acclient_2013_pseudo_c.txt` |
| AP-205 | **Filed 2026-08-11 at Campaign OP gate 4 (#381).** The Apply/Reset/Defaults footer on the Character/Chat/Config tabs draws an opaque, borderless backing field (`UiSolidSpriteFill`, tiling `RetailChromeSprites.CenterFill` — the SAME panel-background sprite the Options window's own `UiNineSlicePanel` chrome already tiles behind everything) behind the three buttons. A live-DAT probe (scratch console app against `DatCollectionAdapter`, 2026-08-11) found retail authors NO such element: each page root (`0x100001F9`/`0x100001FF`/`0x1000050A`) has EXACTLY five children — the row ListBox, its scrollbar, and the three physical buttons — with zero direct-state media on the root itself. Scrolled row content therefore bled through visibly between/behind the buttons before this fix. | `src/AcDream.App/UI/UiSolidSpriteFill.cs`; `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (`AddFooterBacking`) | Reusing the SAME sprite the rest of the window's chrome already draws keeps the synthesized field visually indistinguishable from an authored one rather than inventing a new color; the field is `ClickThrough=true` and z-ordered strictly behind every other child, so it cannot intercept input or occlude the buttons themselves. | A reviewer comparing a byte-exact retail screenshot to acdream will see one extra opaque rect retail never authors — cosmetically invisible (it exactly matches the surrounding chrome), so the only observable difference IS the fix (content no longer bleeding through). If a future page's footer strip ever needs a DIFFERENT background (a themed panel, a translucent tab), this hardcoded `CenterFill` reuse would need revisiting. | Live-DAT probe, 2026-08-11 (page-root child-count/direct-state-media dump against `client_local_English.dat`, LayoutDescs `0x21000028`/`0x21000029`/`0x2100005C`) — no retail element to cite since none exists |
| ~~AP-201~~ | **RETIRED 2026-08-11 at the Campaign OP gate-3 fix round (closes #371).** UiScrollablePanel now marks ClipsChildren=true (the draw walk and hit-test both route through UiRenderContext.PushClip, which existed by retirement time) and its cull predicate keeps any INTERSECTING row visible - a straddling row renders its visible slice instead of vanishing whole. The user-observed symptom this row predicted (the Chat tab per-window filter blocks reading as MISSING at the default scroll offset, gate 3) is the exact acceptance evidence. Original filing follows for the record: filed at the OP5 review-fix round (S2), predates OP5 but was made user-visible by it. `UiTemplateListBox`'s internal row viewport (`UiScrollablePanel.LayoutScrollableChildren`) culls a child WHOLE — `child.Visible = top >= -0.5f && top + child.Height <= Height + 0.5f` — rather than clipping the visible portion of a row that straddles the viewport edge, because the UI renderer has no scissor stack. Retail's own `UIElement_ListBox`/scroll-region rendering clips partially-visible rows at the pixel boundary, same as any native scroll view. Every row in this viewport was 8-36px until Campaign OP slice OP5 added five self-sized filter blocks (12x20=240px / 13x20=260px, AP-195) to the Chat tab's ~560px viewport; a 240-260px block straddling the viewport edge at a given scroll offset now disappears ENTIRELY (a visible "pop") instead of clipping, where the pre-OP5 8-36px rows made the same all-or-nothing cull read as ordinary row-granular scrolling. | `src/AcDream.App/UI/UiScrollablePanel.cs:69` (the cull predicate); consumed by `src/AcDream.App/UI/UiTemplateListBox.cs` (`Viewport`) — the Character/Chat/Config Options-panel tabs and any other controller-built row list sharing this viewport | A scissor stack does not exist anywhere in the retained-UI renderer yet (class's own doc comment, `UiScrollablePanel.cs:8-12`, predates this row); whole-row culling is a correct, cheap stand-in for every list whose rows are small relative to the viewport, which was true for every consumer before OP5. | A tall block (any future row taller than roughly the viewport's own height, not just OP5's filter blocks) can vanish completely for a range of scroll offsets instead of showing a partial view — the OP5 gate script's own step 2 documents the exact symptom so it is not mistaken for a self-sizing regression (`docs/research/2026-08-11-campaign-op-test-script.md`). Scrolling further always restores the block whole; no data or state is lost, only the presentation pops. | No scissor-stack retail oracle needed — this is a stand-in for ordinary native clip-rect rendering every GUI toolkit (including retail's own) provides; issue #371 tracks adding a real per-row clip rect to `UiScrollablePanel` |
| AP-202 | **Filed 2026-08-11 at Campaign OP slice OP8 (D4).** Configure Keyboard persists every rebind to `keybinds.json` only. Retail's own storage is a `<Documents>\Asheron's Call\<name>.keymap` text file (`CInputManager_WIN32::SaveKeyMap @0x00686C20`, `PFileParser`), with Load-File/Save-As buttons for NAMED keymap profiles and a `keymap` key in `UserPreferences.ini` selecting which one loads at startup (research doc §5.7). D4 chose the existing, tested `keybinds.json` schema over building a second `PFileParser`-compatible text codec + named-profile management; this row's the Load File/Save As buttons on the Configure Keyboard screen (`0x10000027`/`0x10000029`) are wired but INERT. | `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`WireScreenButtons`'s Load/Save-As no-op); `src/AcDream.UI.Abstractions/Input/KeyBindings.cs` (`SaveToFile`/`LoadOrDefault`) | `keybinds.json` already round-trips every retail action this screen can bind (identity table + the DAT-defaults conformance test), so the ONLY capability lost is exchanging `.keymap` files with a real retail client or another acdream install by named profile — a real feature gap, not a correctness gap. | A user who expects to export/import a named `.keymap` profile (e.g. to share a control layout with a retail-client friend) cannot; every rebind still works and persists locally. | `docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md` §5.7-§5.8; `CInputManager_WIN32::SaveKeyMap @0x00686C20`; `gmKeyboardUI::SaveKeymap @0x004DCF90` |

View file

@ -476,6 +476,13 @@ regardless of the slider position.
### Filter checkboxes — live routing, per-window independence
**Gate-4 re-test note (#382):** the main chat window's own four indicator
buttons (the small LEDs that light up per floating-window 1-4 open/closed
state) previously rendered NOTHING at rest — only a mouse hover revealed
their orange numbered art. They now show their correct art immediately on
window open, with no hover needed; report if any of the four is still
blank before you've moved your mouse over it.
8. **Open a floating chat window (Alt+1 through Alt+4, or the toolbar
indicator buttons) and uncheck "Combat" in that SAME window's filter
block on the Chat tab** (e.g. Floaty Chat Window 1's block if you opened