fix #416 #415: the retail button state/media machine — roster hover highlight clears; probe wait verbs bind without an artifact dir
#416 (char-select roster highlight never cleared on hover-leave): three decomp-grounded mechanisms replace the media-keyed _availableStates approximation. - UIElement_Button::UpdateState_ @0x00471CF0: the button machine commits ONLY states authored on the button's OWN ElementDesc (AccessStateDesc gate); unauthored requests no-op, preserving custom semantic states. - UIElement::SetState @0x00464E70: an unauthored state id is coerced to state 0 (the unnamed base state) and committed — ported into UiDatElement.TrySetRetailState with the base-descriptor PassToChildren cascade arm. - The SetState media rule @0x004651c0: a committed state replaces the playing media ONLY when its media array is non-empty. UiButton now keeps per-face-segment media states under that rule (segments model retail's PassToChildren children), and LayoutImporter records the raw MediaCount including the File=0 draw-nothing images the drawable filter drops — the roster bar children's base state is exactly such an image, and it is what clears the bar. The row template truth (probe, installed DAT): the row authors EMPTY Normal/rollover/Highlight descriptors with PassToChildren; the three bar children author rollover/Highlight media, NO Normal state, and a File=0 base image. An empty-media Normal_pressed still never blanks a Normal-art button (the media rule keeps the previous art — the exact behavior the old gate approximated), and the Appearance spins' property-only Highlight now genuinely commits: label recolors, arrow art lingers — the retail split AP-222 approximated with a requested-keyed label hack, now retired. Live-verified at char select: hover +alex shows the grey bar, moving off clears it, the selected row keeps its amber bar. #415 (probe wait world-* verbs dead): the filed snapshot-reset diagnosis was wrong — the automation bridge simply never bound without ACDREAM_AUTOMATION_ARTIFACT_DIR. A facts-only WorldRevealFactsAutomationRuntime now binds whenever the retained UI exists; checkpoint/screenshot verbs still require the artifact directory and now report that instead of a generic timeout. App tests 5568/3 skips, Runtime 1756/0, UI.Abstractions 926/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7aa08045d8
commit
91c1962b0d
11 changed files with 469 additions and 86 deletions
|
|
@ -36,7 +36,9 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
private readonly ElementInfo _mediaInfo;
|
||||
private readonly FaceSegment[] _faceSegments;
|
||||
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
||||
private readonly HashSet<uint> _availableStates = new();
|
||||
private readonly string[] _segmentMediaStates;
|
||||
private string _faceMediaState = "";
|
||||
private string? _lastMediaCommitState;
|
||||
private readonly bool _hasCustomSelectionPair;
|
||||
private IReadOnlyDictionary<uint, Vector4>? _stateLabelColors;
|
||||
private IReadOnlyDictionary<uint, bool>? _stateLabelOutlines;
|
||||
|
|
@ -433,19 +435,15 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
_faceSegments = faceSegments is null
|
||||
? []
|
||||
: faceSegments.Select(static segment => new FaceSegment(segment)).ToArray();
|
||||
// Retail media start: the media machine begins on the element's BASE
|
||||
// media (m_desc.m_media); the first committed state then applies the
|
||||
// SetState media rule (see SyncMediaStates) — including the default
|
||||
// state at construction, exactly retail's Initialize -> SetState
|
||||
// ordering.
|
||||
_segmentMediaStates = new string[_faceSegments.Length];
|
||||
_resolve = resolve;
|
||||
ClickThrough = false; // buttons are interactive — opt OUT of click-through
|
||||
|
||||
// Visual transitions can select only states with an actual button face.
|
||||
// Retail layouts commonly declare an empty Normal_pressed descriptor while
|
||||
// supplying art only for Normal/Highlight. Treating that property-only state
|
||||
// as drawable briefly blanks the button during mouse-down.
|
||||
if (_faceSegments.Length == 0)
|
||||
AddAvailableStates(_mediaInfo);
|
||||
else
|
||||
foreach (FaceSegment segment in _faceSegments)
|
||||
AddAvailableStates(segment.Info);
|
||||
|
||||
// Campaign CC gate round 1 Batch B (GF-1/GF-8): retail's custom
|
||||
// "Unselected"/"Selected" radio-selection state pair
|
||||
// (RetailUiStateIds.Unselected/Selected, 0x10000016/0x10000017) is
|
||||
|
|
@ -497,21 +495,85 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
public override bool HandlesClick => true;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the File id for the current <see cref="ActiveState"/>, falling back to
|
||||
/// the DirectState ("" key) if the named state is absent.
|
||||
/// Returns 0 if neither exists.
|
||||
/// Mirrors <see cref="UiDatElement.ActiveMedia()"/>.
|
||||
/// Retail's SetState media rule (<c>UIElement::SetState @0x00464E70</c>
|
||||
/// tail, the <c>m_media.m_num != 0</c> gate @0x004651c0): a committed
|
||||
/// state replaces the playing media ONLY when its media array is
|
||||
/// non-empty — an authored empty-media state keeps the PREVIOUS media
|
||||
/// (why an empty <c>Normal_pressed</c> never blanks a Normal-art
|
||||
/// button), while an authored File=0 draw-nothing image counts as media
|
||||
/// and clears the face (#416: the roster-row bar children's base
|
||||
/// state). An UNAUTHORED committed state runs retail's state-0 arm
|
||||
/// against the base media array. Face segments model retail's
|
||||
/// PassToChildren children, so each segment resolves the rule against
|
||||
/// its OWN authored states. Synced lazily on the first draw after any
|
||||
/// <see cref="ActiveState"/> write so every commit path (the visual
|
||||
/// state machine, TrySetRetailState, external assignments) is covered.
|
||||
/// </summary>
|
||||
private uint ActiveFile(ElementInfo mediaInfo)
|
||||
=> mediaInfo.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
|
||||
: mediaInfo.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
|
||||
private void SyncMediaStates()
|
||||
{
|
||||
if (string.Equals(ActiveState, _lastMediaCommitState, StringComparison.Ordinal))
|
||||
return;
|
||||
uint committedId = ActiveRetailStateId;
|
||||
if (_faceSegments.Length == 0)
|
||||
{
|
||||
_faceMediaState = NextMediaState(
|
||||
_mediaInfo, committedId, ActiveState, _faceMediaState);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < _faceSegments.Length; i++)
|
||||
{
|
||||
_segmentMediaStates[i] = NextMediaState(
|
||||
_faceSegments[i].Info,
|
||||
committedId,
|
||||
ActiveState,
|
||||
_segmentMediaStates[i]);
|
||||
}
|
||||
}
|
||||
_lastMediaCommitState = ActiveState;
|
||||
}
|
||||
|
||||
private static string NextMediaState(
|
||||
ElementInfo info,
|
||||
uint committedId,
|
||||
string committedName,
|
||||
string current)
|
||||
{
|
||||
if (info.States.TryGetValue(committedId, out UiStateInfo? state))
|
||||
return state.MediaCount != 0 ? committedName : current;
|
||||
// Synthetic/test infos may carry StateMedia without States entries;
|
||||
// a drawable entry for the committed name counts as authored media.
|
||||
if (info.StateMedia.ContainsKey(committedName))
|
||||
return committedName;
|
||||
// Retail's state-0 arm: base media if its array is non-empty,
|
||||
// otherwise the previous media keeps playing.
|
||||
if (info.States.TryGetValue(
|
||||
UiStateInfo.DirectStateId, out UiStateInfo? baseState))
|
||||
return baseState.MediaCount != 0 ? "" : current;
|
||||
return info.StateMedia.ContainsKey("") ? "" : current;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the File id the media rule selected for this face; 0 draws
|
||||
/// nothing (an authored File=0 image reaches this as a media-state whose
|
||||
/// name has no drawable entry).
|
||||
/// </summary>
|
||||
private static uint ActiveFile(ElementInfo mediaInfo, string mediaState)
|
||||
=> mediaInfo.StateMedia.TryGetValue(mediaState, out var m) ? m.File : 0u;
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
SyncMediaStates();
|
||||
if (_faceSegments.Length != 0)
|
||||
{
|
||||
foreach (FaceSegment segment in _faceSegments)
|
||||
DrawFace(ctx, ActiveFile(segment.Info), segment.Rect(Width, Height));
|
||||
for (int i = 0; i < _faceSegments.Length; i++)
|
||||
{
|
||||
FaceSegment segment = _faceSegments[i];
|
||||
DrawFace(
|
||||
ctx,
|
||||
ActiveFile(segment.Info, _segmentMediaStates[i]),
|
||||
segment.Rect(Width, Height));
|
||||
}
|
||||
}
|
||||
else if (ColorKeyFaceResolver is { } colorKeyResolver)
|
||||
{
|
||||
|
|
@ -531,7 +593,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
}
|
||||
else
|
||||
{
|
||||
uint file = FaceFileOverride ?? ActiveFile(_mediaInfo);
|
||||
uint file = FaceFileOverride ?? ActiveFile(_mediaInfo, _faceMediaState);
|
||||
if (file != 0)
|
||||
{
|
||||
var (tex, tw, th) = _resolve(file);
|
||||
|
|
@ -769,13 +831,6 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
Tint);
|
||||
}
|
||||
|
||||
private void AddAvailableStates(ElementInfo mediaInfo)
|
||||
{
|
||||
foreach (string stateName in mediaInfo.StateMedia.Keys)
|
||||
if (UiButtonStateMachine.TryStateId(stateName, out uint stateId))
|
||||
_availableStates.Add(stateId);
|
||||
}
|
||||
|
||||
private bool HasStateMedia(string stateName)
|
||||
{
|
||||
if (_faceSegments.Length == 0)
|
||||
|
|
@ -979,29 +1034,38 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
// never author rollover or pressed media for the pair, so
|
||||
// there is nothing faithful to compute beyond selected-or-not.
|
||||
ActiveState = RetailUiStateIds.StateName(requested);
|
||||
}
|
||||
else if (_availableStates.Contains(requested))
|
||||
{
|
||||
ActiveState = UiButtonStateMachine.StateName(requested);
|
||||
ApplyPerStateLabelStyle(requested);
|
||||
CascadeStateToChildren(requested);
|
||||
return;
|
||||
}
|
||||
|
||||
// AP-222: apply the per-state label style off the REQUESTED id, not
|
||||
// the (possibly art-gated) committed ActiveState — retail's own
|
||||
// SetState(6) commits the state's PROPERTIES (including text color)
|
||||
// unconditionally; only the SPRITE draw silently no-ops when a
|
||||
// state has no media (this class's own #382 comment on
|
||||
// TrySetRetailState documents the same distinction). The
|
||||
// Appearance spins' current-part highlight is exactly this case:
|
||||
// _availableStates never contains Highlight (their arrow face
|
||||
// segments carry no Highlight art), so ActiveState stays "Normal"
|
||||
// forever, but the spin's OWN label color must still swap.
|
||||
// Retail UIElement_Button::UpdateState_ @0x00471CF0: the machine
|
||||
// calls SetState ONLY when the requested state is authored on the
|
||||
// button's OWN ElementDesc (the AccessStateDesc gate @0x00471d8e) —
|
||||
// an unauthored request is a NO-OP that preserves the current state
|
||||
// (how custom semantic states like Minimized survive pointer
|
||||
// traffic). The commit itself (UIElement::SetState @0x00464E70)
|
||||
// applies the state's properties and PassToChildren cascade; the
|
||||
// face's DRAWN media follows the separate SetState media rule
|
||||
// (SyncMediaStates — a committed state replaces the playing media
|
||||
// only when its media array is non-empty, @0x004651c0). The former
|
||||
// media-keyed _availableStates gate here latched the #416
|
||||
// roster-row highlight: the row authors an EMPTY 'Normal'
|
||||
// descriptor whose commit must reach the bar segments' own state-0
|
||||
// File=0 clear, but a media-keyed gate could never commit it.
|
||||
// Synthetic/test infos may carry StateMedia without States entries,
|
||||
// so a drawable entry for the requested name also counts as
|
||||
// authored.
|
||||
string requestedName = UiButtonStateMachine.StateName(requested);
|
||||
bool authored = _info.States.TryGetValue(
|
||||
requested, out UiStateInfo? committed);
|
||||
if (!authored && !HasStateMedia(requestedName))
|
||||
return;
|
||||
|
||||
ActiveState = authored && !string.IsNullOrEmpty(committed!.Name)
|
||||
? committed.Name
|
||||
: requestedName;
|
||||
ApplyPerStateLabelStyle(requested);
|
||||
|
||||
// Same unconditional-commit principle for the child cascade: retail
|
||||
// UIElement::SetState @0x00464E70 propagates the committed state to
|
||||
// children whenever the StateDesc authors PassToChildren, regardless
|
||||
// of whether THIS element's own sprite changed — keyed off the
|
||||
// REQUESTED id for the same reason as the label style above.
|
||||
CascadeStateToChildren(requested);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue