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

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