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:
Erik 2026-08-17 16:25:36 +02:00
parent 7aa08045d8
commit 91c1962b0d
11 changed files with 469 additions and 86 deletions

View file

@ -524,6 +524,22 @@ internal sealed class FrameRootCompositionPhase
interaction.LateBindings.Automation.Bind(
lifecycleAutomation));
}
else if (interaction.RetainedUi is not null)
{
// #415: without ACDREAM_AUTOMATION_ARTIFACT_DIR the deferred
// automation wrapper stayed unbound, so a probe script's
// `wait world-ready/world-visible/materialized` verbs read
// false forever and timed out even while the world revealed.
// The wait verbs need only the reveal facts — bind them always;
// checkpoint/screenshot verbs keep requiring the artifact
// directory and now report that instead of a generic timeout.
bindings.Adopt(
"world reveal facts automation binding",
interaction.LateBindings.Automation.Bind(
new WorldRevealFactsAutomationRuntime(
() => session.WorldReveal.Snapshot,
() => session.WorldReveal.PortalMaterializationCount)));
}
Fault(FrameRootCompositionPoint.LifecycleAutomationBound);
IRetainedGameplayUiFrame? retainedGameplayUi =

View file

@ -164,6 +164,61 @@ internal sealed class WorldLifecycleCheckpointRequest :
}
}
/// <summary>
/// #415: the facts-only automation runtime, bound whenever the full
/// <see cref="WorldLifecycleAutomationController"/> is NOT composed (no
/// <c>ACDREAM_AUTOMATION_ARTIFACT_DIR</c>). The probe script's
/// <c>wait world-ready/world-visible/materialized</c> verbs need only the
/// reveal snapshot — which exists in every launch — yet before this class
/// they silently read <c>false</c> forever through the unbound deferred
/// wrapper, timing out even while the <c>[world-reveal]</c> stream showed
/// the awaited edge. Checkpoint/screenshot verbs still require the artifact
/// directory and now say so instead of failing generically.
/// </summary>
internal sealed class WorldRevealFactsAutomationRuntime
: IRetailUiAutomationRuntime
{
private readonly Func<RuntimePortalSnapshot> _getReveal;
private readonly Func<int> _getPortalMaterializationCount;
public WorldRevealFactsAutomationRuntime(
Func<RuntimePortalSnapshot> getReveal,
Func<int> getPortalMaterializationCount)
{
_getReveal = getReveal
?? throw new ArgumentNullException(nameof(getReveal));
_getPortalMaterializationCount = getPortalMaterializationCount
?? throw new ArgumentNullException(
nameof(getPortalMaterializationCount));
}
public bool IsWorldReady => _getReveal().IsReady;
public bool IsWorldViewportVisible => _getReveal().WorldViewportObserved;
public int PortalMaterializationCount => _getPortalMaterializationCount();
public bool TryRequestCheckpoint(
string name,
out IRetailUiAutomationCheckpoint? checkpoint,
out string error)
{
checkpoint = null;
error = "checkpoints require ACDREAM_AUTOMATION_ARTIFACT_DIR";
return false;
}
public void CancelCheckpoint(IRetailUiAutomationCheckpoint checkpoint)
{
}
public bool TryRequestScreenshot(string name, out string error)
{
error = "screenshots require ACDREAM_AUTOMATION_ARTIFACT_DIR";
return false;
}
public bool IsScreenshotComplete(string name) => false;
}
/// <summary>
/// Diagnostic-only runtime seam used by production retained-UI scripts. It
/// writes one structured checkpoint at explicit script edges and delegates GL

View file

@ -603,6 +603,10 @@ public static class LayoutImporter
Name = name,
PassToChildren = sd.PassToChildren,
IncorporationFlags = (uint)sd.IncorporationFlags,
// Raw media presence, INCLUDING File=0 draw-nothing images the
// image filter below drops — retail's SetState media-machine
// reset gates on m_media.m_num != 0 (#416; see UiStateInfo).
MediaCount = sd.Media.Count,
};
bool imageRead = false;

View file

@ -103,7 +103,33 @@ public class UiDatElement : UiElement, IUiDatStateful
if (string.IsNullOrEmpty(stateName))
stateName = RetailUiStateIds.StateName(stateId);
if (string.IsNullOrEmpty(stateName) || !Info.StateMedia.ContainsKey(stateName))
return false;
{
// Retail UIElement::SetState @0x00464E70: AccessStateDesc on an
// UNAUTHORED state id coerces the request to STATE 0 — the
// unnamed base state — and commits it (m_state/m_curStateDesc
// are written unconditionally), so the previous state's media
// can never linger. The old refusal here latched state media
// forever (#416): the character-select roster row's highlight
// bar children (0x10000481/82/83 in 0x21000004) author
// Normal_rollover/Highlight media but NO 'Normal' state at
// all, so the row's PassToChildren 'Normal' hover-leave
// cascade landed here and the bars never cleared. Retail's
// state-0 arm cascades state 0 to children off the BASE
// descriptor's own PassToChildren (m_desc.m_bPassToChildren,
// @0x00464eca), and the per-state Invisible honor below stays
// scoped to NAMED authored states exactly as before (the #408
// gate) — selectedState remains null on this path.
ActiveState = "";
if (Info.States.TryGetValue(
UiStateInfo.DirectStateId, out UiStateInfo? baseState)
&& baseState.PassToChildren)
{
foreach (UiElement child in Children)
if (child is IUiDatStateful stateful)
stateful.TrySetRetailState(UiStateInfo.DirectStateId);
}
return true;
}
ActiveState = stateName;
}

View file

@ -126,6 +126,18 @@ public sealed class UiStateInfo
public UiCursorMedia? Cursor;
public UiPropertyBag Properties = new();
/// <summary>
/// Raw authored MediaDesc count for this state, INCLUDING File=0
/// draw-nothing images that <see cref="Image"/>/StateMedia deliberately
/// drop. Retail's <c>UIElement::SetState @0x00464E70</c> tail resets the
/// media machine only when the committed state's media array is
/// NON-EMPTY (<c>m_media.m_num != 0</c> gate @0x004651c0) — an authored
/// state with an empty media array keeps the PREVIOUS media playing,
/// while an authored File=0 image counts as media and clears the face
/// (#416).
/// </summary>
public int MediaCount;
public UiStateInfo Clone()
=> new()
{
@ -136,6 +148,7 @@ public sealed class UiStateInfo
Image = Image,
Cursor = Cursor,
Properties = Properties.Clone(),
MediaCount = MediaCount,
};
public static UiStateInfo Merge(UiStateInfo baseState, UiStateInfo derivedState)
@ -148,5 +161,11 @@ public sealed class UiStateInfo
Image = derivedState.Image ?? baseState.Image,
Cursor = derivedState.Cursor ?? baseState.Cursor,
Properties = UiPropertyBag.Merge(baseState.Properties, derivedState.Properties),
// Media arrays do not merge entry-wise in retail (a derived
// StateDesc replaces the base one); the derived count wins when
// the derived state authors ANY media, else the base's carries.
MediaCount = derivedState.MediaCount != 0
? derivedState.MediaCount
: baseState.MediaCount,
};
}

View file

@ -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);
}