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:
parent
a31fd631ad
commit
d1c60df946
6 changed files with 292 additions and 10 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -251,7 +251,31 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
|
||||
if (stateId == UiStateInfo.DirectStateId)
|
||||
{
|
||||
if (!TryFindState(stateId, out _) && !HasStateMedia(""))
|
||||
// #382: a DirectState transition only actually applies when the button
|
||||
// has REAL "" media to show. Every button structurally carries a
|
||||
// DirectStateId entry in _mediaInfo.States purely as the property bag
|
||||
// for its own base-level dat properties (ToggleBehavior 0x0B, RolloverEnabled
|
||||
// 0x13, etc. — see UiButtonTests.AddBoolProperty), independent of whether
|
||||
// it authors any blank/unnamed sprite. TryFindState(DirectStateId) succeeding
|
||||
// on that property-only entry used to be enough to accept the transition
|
||||
// (the retail-decompiled UIElement::SetState @0x00464e70 commits m_curStateDesc
|
||||
// unconditionally once ElementDesc::AccessStateDesc finds ANY StateDesc, media
|
||||
// or not — retail's OWN buttons dodge the resulting blank draw purely through
|
||||
// construction TIMING: Initialize()'s SetState(m_defaultState) call happens
|
||||
// before m_children is populated, so a PassToChildren cascade from an ancestor
|
||||
// can never reach an already-initialized child during import). Our port's
|
||||
// LayoutImporter.BuildWidget deliberately reapplies a PARENT's default AFTER its
|
||||
// children are built (so retained PassToChildren tabs get their authored
|
||||
// Open/Closed child media — see that method's own comment), which means a
|
||||
// chrome ancestor's structural (media-less) DirectState — e.g. the floating
|
||||
// chat window's indicator-button backing panel, which authors PassToChildren=
|
||||
// true on its own empty DirectState purely to route HideDetail/ShowDetail to
|
||||
// an unrelated sibling — can and does reach an already-correctly-resolved
|
||||
// button (ActiveState="Normal") and blank it before first paint. Requiring
|
||||
// actual "" media closes that gap without touching the reapply ordering (which
|
||||
// CharacterStatController's three-chrome-children PassToChildren cascade still
|
||||
// depends on) or the cascade mechanism itself (both remain faithful ports).
|
||||
if (!HasStateMedia(""))
|
||||
return false;
|
||||
ActiveState = "";
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
using System.IO;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// #382 (Campaign OP gate 4, 2026-08-11): the main chat window's four
|
||||
/// floating-window indicator buttons (<c>0x10000522</c>-<c>0x10000525</c>,
|
||||
/// <c>ChatWindowController</c>'s own <c>Indicator1Id</c>..<c>Indicator4Id</c>)
|
||||
/// rendered NOTHING at rest — only a mouse hover revealed the correct orange
|
||||
/// numbered-button art.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>ROOT CAUSE</b> (found via a reference-identity-verified live-DAT probe,
|
||||
/// not a guess): the buttons' OWN construction resolves <c>ActiveState=
|
||||
/// "Normal"</c> correctly (DefaultStateName="Normal", StateMedia has both
|
||||
/// "Normal"/"Highlight"). The BUG fires immediately afterward, still inside
|
||||
/// the SAME <c>LayoutImporter.Build</c> call: the indicator column's backing
|
||||
/// panel (<c>0x10000600</c>) authors <c>PassToChildren=true</c> on its OWN
|
||||
/// empty DirectState (verified live: <c>States[0xFFFFFFFF].PassToChildren ==
|
||||
/// true</c>, presumably intended to route a DIFFERENT pair of named states —
|
||||
/// HideDetail/ShowDetail — to an unrelated sibling, not Normal/Highlight to
|
||||
/// these buttons). <c>LayoutImporter.BuildWidget</c> reapplies every
|
||||
/// widget's own default state AFTER its children are attached (so retained
|
||||
/// PassToChildren TABS get their authored Open/Closed child media); when the
|
||||
/// PANEL's OWN reapply runs, <c>UiDatElement.TrySetRetailState</c> cascades
|
||||
/// its DirectStateId to EVERY <c>IUiDatStateful</c> child — including the
|
||||
/// ALREADY-CORRECTLY-RESOLVED buttons. <c>UiButton.TrySetRetailState</c>'s
|
||||
/// DirectStateId branch used to accept that cascade because
|
||||
/// <c>_mediaInfo.States</c> 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
|
||||
/// <c>UiButtonTests.AddBoolProperty</c>), so <c>TryFindState(DirectStateId)</c>
|
||||
/// found that entry and blanked <c>ActiveState</c> to "" even though
|
||||
/// <c>StateMedia</c> has no "" key at all. A synthetic hover "fixed" it only
|
||||
/// because <c>UiButtonStateMachine.RequestedState</c> resolves to the SAME
|
||||
/// canonical Normal id regardless of PointerOver when RolloverEnabled is
|
||||
/// false, so the NEXT <c>UpdateVisualState()</c> call (from the hover event)
|
||||
/// re-picks "Normal" from <c>_availableStates</c> — the construction-time
|
||||
/// blanking was a one-shot event, not a persistent "always blank" state.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Fix</b> (<see cref="UiButton.TrySetRetailState"/>): the DirectStateId
|
||||
/// branch now requires REAL "" media (<c>HasStateMedia("")</c>) before
|
||||
/// accepting the transition — a structurally-present-but-media-less States
|
||||
/// entry no longer counts. This is scoped to <c>UiButton</c> only; the
|
||||
/// cascade mechanism itself and <c>LayoutImporter.BuildWidget</c>'s reapply
|
||||
/// ordering are UNCHANGED, so <c>CharacterStatController</c>'s own
|
||||
/// PassToChildren-driven chrome children (which rely on the SAME cascade,
|
||||
/// see its own class doc) are unaffected.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ChatIndicatorButtonLiveMountProbeTests
|
||||
{
|
||||
[Fact]
|
||||
public void IndicatorButtons_ResolveNormalStateAtRest_ThroughTheLiveImportPath()
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1")
|
||||
return;
|
||||
|
||||
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||
?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
var strings = new DatStringResolver(dats);
|
||||
|
||||
// The PRODUCTION main chat window layout (ChatWindowController.LayoutId),
|
||||
// not the standalone popup-catalog id VendorUiController's own doc cites
|
||||
// for a DIFFERENT purpose (the channel menu's popup catalog).
|
||||
ElementInfo? rootInfo = LayoutImporter.ImportInfos(dats, ChatWindowController.LayoutId);
|
||||
Assert.NotNull(rootInfo);
|
||||
|
||||
// Confirm the root cause is still live in the DAT (documents WHY this bug
|
||||
// class exists — if this ever flips false, the fix below becomes inert but
|
||||
// harmless, so this is a documentation assertion, not a fix precondition).
|
||||
ElementInfo? parentInfo = FindInfo(rootInfo!, 0x10000600u);
|
||||
Assert.NotNull(parentInfo);
|
||||
Assert.True(
|
||||
parentInfo!.States.TryGetValue(UiStateInfo.DirectStateId, out UiStateInfo? parentDirect)
|
||||
&& parentDirect.PassToChildren,
|
||||
"the indicator column's backing panel (0x10000600) no longer authors " +
|
||||
"PassToChildren on its own DirectState — the #382 root-cause premise has " +
|
||||
"changed; re-verify the fix is still needed.");
|
||||
|
||||
ImportedLayout layout = LayoutImporter.Build(
|
||||
rootInfo!,
|
||||
resolve: _ => (1u, 16, 16), // fake non-zero texture handle — sprite CONTENT doesn't matter here
|
||||
datFont: null,
|
||||
fontResolve: null,
|
||||
stringResolve: strings.Resolve);
|
||||
|
||||
uint[] indicatorIds = { 0x10000522u, 0x10000523u, 0x10000524u, 0x10000525u };
|
||||
foreach (uint id in indicatorIds)
|
||||
{
|
||||
UiElement? el = layout.FindElement(id);
|
||||
Assert.True(el is UiButton, $"0x{id:X8} did not build as a UiButton (was {el?.GetType().Name ?? "null"}).");
|
||||
var button = (UiButton)el!;
|
||||
|
||||
// The regression: this used to read "" immediately after import, because
|
||||
// the parent panel's PassToChildren cascade (confirmed above) blanked it
|
||||
// AFTER the button's own construction had already resolved "Normal".
|
||||
Assert.Equal("Normal", button.ActiveState);
|
||||
|
||||
// TrySetRetailState(Normal) — the SAME call LayoutImporter's own reapply
|
||||
// makes — must still work normally now that ToggleBehavior routes it
|
||||
// through the Selected setter.
|
||||
Assert.True(button.TrySetRetailState(UiButtonStateMachine.Normal));
|
||||
Assert.Equal("Normal", button.ActiveState);
|
||||
|
||||
// A cascaded DirectStateId (what the parent's reapply actually sends)
|
||||
// must now be REJECTED rather than silently blanking an already-good
|
||||
// state — the heart of the fix.
|
||||
Assert.False(button.TrySetRetailState(UiStateInfo.DirectStateId));
|
||||
Assert.Equal("Normal", button.ActiveState);
|
||||
|
||||
// Hover/leave still behave exactly as before (RolloverEnabled=false means
|
||||
// PointerOver never changes the resolved state).
|
||||
button.OnEvent(new UiEvent(0, button, UiEventType.HoverEnter));
|
||||
Assert.Equal("Normal", button.ActiveState);
|
||||
button.OnEvent(new UiEvent(0, button, UiEventType.HoverLeave));
|
||||
Assert.Equal("Normal", button.ActiveState);
|
||||
}
|
||||
}
|
||||
|
||||
private static ElementInfo? FindInfo(ElementInfo node, uint id)
|
||||
{
|
||||
if (node.Id == id) return node;
|
||||
foreach (ElementInfo child in node.Children)
|
||||
{
|
||||
ElementInfo? found = FindInfo(child, id);
|
||||
if (found is not null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -162,6 +162,46 @@ public class UiButtonTests
|
|||
Assert.Equal("Normal", b.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectStateCascade_WithoutRealMedia_DoesNotBlankAnAlreadyResolvedState()
|
||||
{
|
||||
// #382: LayoutImporter.BuildWidget's post-attach state reapply cascades a
|
||||
// PARENT's PassToChildren DirectState to EVERY IUiDatStateful child (the
|
||||
// chat window's indicator-button backing panel, 0x10000600, authors exactly
|
||||
// this). Every button structurally carries a DirectStateId entry in its own
|
||||
// States dict purely as the property bag for ToggleBehavior/RolloverEnabled/
|
||||
// etc (see AddBoolProperty below) — that structural presence must NOT be
|
||||
// enough to accept a DirectState transition when the button has no real ""
|
||||
// media, or an ancestor's unrelated cascade blanks an already-correct
|
||||
// "Normal" resolution before first paint.
|
||||
var info = ButtonInfo("Normal", "Highlight");
|
||||
AddBoolProperty(info, 0x13u, true); // RolloverEnabled — populates States[DirectStateId]
|
||||
var b = CreateButton(info);
|
||||
Assert.Equal("Normal", b.ActiveState);
|
||||
|
||||
bool ok = b.TrySetRetailState(UiStateInfo.DirectStateId);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Equal("Normal", b.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectStateTransition_WithRealMedia_StillSucceeds()
|
||||
{
|
||||
// The companion positive case: a button that legitimately authors ""
|
||||
// (DirectState) media must still be able to transition to it explicitly —
|
||||
// the fix narrows the check to "has real media", it does not disable the
|
||||
// DirectState branch outright.
|
||||
var info = ButtonInfo("Normal");
|
||||
info.StateMedia[""] = (7u, 1);
|
||||
var b = CreateButton(info);
|
||||
|
||||
bool ok = b.TrySetRetailState(UiStateInfo.DirectStateId);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("", b.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HotClick_FiresImmediatelyRepeatsAndSuppressesReleaseClick()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue