diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 22b0e4bb..de358652 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -24,22 +24,57 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
-## #415 — UI-probe `wait world-visible` verb is dead after reveal completion (automation bridge reads the reset snapshot)
+## #416 — Character-select roster hover highlight never clears (sweeping the roster leaves every row highlighted)
-**Status:** OPEN (filed 2026-08-17 at the #414 cursor repro). The script
-runner's `wait world-visible` polls
-`WorldLifecycleAutomationController.IsWorldViewportVisible`, which reads
-`_getReveal().WorldViewportObserved` from the LIVE transit snapshot. The
-reveal's `event=complete` retires that generation and the snapshot resets
-`WorldViewportObserved: false` (`RuntimeWorldTransitState.cs:583`), so the
-verb only observes true during the sub-second window between
-`event=world-visible` and `event=complete` — in practice it times out even
-though the world revealed (proved by the #414 repro logs: `wait
-world-visible 60000` timed out with `event=world-visible` present in the
-same run's `[world-reveal]` stream). Fix shape: the automation controller
-should latch world-visible per generation (or the verb should accept
-`IsWorldReady`-style completed state), not read the transient snapshot.
-Test apparatus only — no player impact.
+**Status:** ✅ FIXED 2026-08-17 (same round as #414; fix + tests in the same
+commit as this entry). **Symptom:** hovering a roster row highlights it, but
+moving off leaves the highlight on — sweep all rows and every one stays lit.
+**Root cause chain (decomp-grounded):** the roster row template
+(`0x21000004/0x100003A5`) is a media-less button whose three bar children
+(`0x10000481/82/83`) author `Normal_rollover`/`Highlight` media but NO
+`Normal` state — their BASE state instead authors a **File=0 draw-nothing
+image**. Retail clears the bar through two mechanisms our port approximated
+away: (1) `UIElement_Button::UpdateState_ @0x00471CF0` gates the machine on
+`AccessStateDesc` — any AUTHORED state commits (the row's empty
+`Normal` descriptor included), unauthored requests no-op; (2)
+`UIElement::SetState @0x00464E70`'s tail (`@0x004651c0`) resets the media
+machine ONLY when the committed state's media array is NON-EMPTY — an
+empty-media state keeps the previous media (why an empty `Normal_pressed`
+never blanks a Normal-art button), an unauthored state commits state 0
+whose BASE media applies, and the bar children's File=0 base image is what
+draws-nothing. Our media-keyed `_availableStates` gate refused the row's
+empty `Normal` commit outright, latching the rollover forever. **Fix:**
+`UiButton` now ports the machine gate (authored-on-own-desc) and the
+SetState media rule (per-face-segment media states with the non-empty-array
+reset gate; `LayoutImporter` records raw `MediaCount` including File=0
+entries); `UiDatElement.TrySetRetailState` gained retail's
+unauthored→state-0 arm with the base-descriptor `PassToChildren` cascade.
+Retires the AP-222-era requested-keyed label hack (the spins' property-only
+`Highlight` now genuinely commits — label recolors, arrow art lingers, the
+exact retail split). Live-verified at char select: hover `+alex` → grey bar;
+move off → bar clears; selected row keeps its amber bar. Tests:
+`UiButtonTests` (pressed-state face linger via draw capture, property-only
+Highlight commit, machine no-op preservation), `UiDatElementTests` (state-0
+commit/cascade/#408 guard), live-DAT spin pin updated to the commit truth.
+
+## #415 — UI-probe `wait world-*` verbs are dead without `ACDREAM_AUTOMATION_ARTIFACT_DIR` (unbound deferred automation wrapper)
+
+**Status:** ✅ FIXED 2026-08-17 (same round as #414/#416; fix in the same
+commit as this entry's flip). Filed as "reads the reset snapshot" — that
+diagnosis was WRONG: the completed reveal KEEPS `WorldViewportObserved`
+(only the next `BeginRevealCore` clears it). The actual cause:
+`FrameRootComposition` binds the `WorldLifecycleAutomationController` (and
+therefore the probe runtime bridge) ONLY when
+`ACDREAM_AUTOMATION_ARTIFACT_DIR` is configured; without it the
+`DeferredWorldLifecycleAutomationRuntime` wrapper stays unbound and every
+`wait world-ready/world-visible/materialized` verb silently reads false
+until timeout — even while the `[world-reveal]` stream shows the awaited
+edge (the #414 repro logs proved exactly this). **Fix:** a facts-only
+`WorldRevealFactsAutomationRuntime` now binds whenever the retained UI
+exists and the full controller is not composed — the wait verbs need only
+the reveal snapshot, which every launch has; checkpoint/screenshot verbs
+still require the artifact directory and now say so explicitly instead of
+failing with a generic timeout. Test apparatus only — no player impact.
## #414 — Mouse cursor disappears at character select after the in-world logoff (teardown fly-mode fallback raw-captures the cursor)
diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs
index c48ee93e..7494efc3 100644
--- a/src/AcDream.App/Composition/FrameRootComposition.cs
+++ b/src/AcDream.App/Composition/FrameRootComposition.cs
@@ -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 =
diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs
index 63d7ee32..0ac7241e 100644
--- a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs
+++ b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs
@@ -164,6 +164,61 @@ internal sealed class WorldLifecycleCheckpointRequest :
}
}
+///
+/// #415: the facts-only automation runtime, bound whenever the full
+/// is NOT composed (no
+/// ACDREAM_AUTOMATION_ARTIFACT_DIR). The probe script's
+/// wait world-ready/world-visible/materialized verbs need only the
+/// reveal snapshot — which exists in every launch — yet before this class
+/// they silently read false forever through the unbound deferred
+/// wrapper, timing out even while the [world-reveal] stream showed
+/// the awaited edge. Checkpoint/screenshot verbs still require the artifact
+/// directory and now say so instead of failing generically.
+///
+internal sealed class WorldRevealFactsAutomationRuntime
+ : IRetailUiAutomationRuntime
+{
+ private readonly Func _getReveal;
+ private readonly Func _getPortalMaterializationCount;
+
+ public WorldRevealFactsAutomationRuntime(
+ Func getReveal,
+ Func 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;
+}
+
///
/// Diagnostic-only runtime seam used by production retained-UI scripts. It
/// writes one structured checkpoint at explicit script edges and delegates GL
diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs
index 6bc56c04..e7125ed9 100644
--- a/src/AcDream.App/UI/Layout/LayoutImporter.cs
+++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs
@@ -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;
diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs
index 4027acd0..733cd3ff 100644
--- a/src/AcDream.App/UI/Layout/UiDatElement.cs
+++ b/src/AcDream.App/UI/Layout/UiDatElement.cs
@@ -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;
}
diff --git a/src/AcDream.App/UI/Layout/UiPropertyBag.cs b/src/AcDream.App/UI/Layout/UiPropertyBag.cs
index de97aa43..35d95956 100644
--- a/src/AcDream.App/UI/Layout/UiPropertyBag.cs
+++ b/src/AcDream.App/UI/Layout/UiPropertyBag.cs
@@ -126,6 +126,18 @@ public sealed class UiStateInfo
public UiCursorMedia? Cursor;
public UiPropertyBag Properties = new();
+ ///
+ /// Raw authored MediaDesc count for this state, INCLUDING File=0
+ /// draw-nothing images that /StateMedia deliberately
+ /// drop. Retail's UIElement::SetState @0x00464E70 tail resets the
+ /// media machine only when the committed state's media array is
+ /// NON-EMPTY (m_media.m_num != 0 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).
+ ///
+ 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,
};
}
diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs
index 782b32cc..bdba5f1d 100644
--- a/src/AcDream.App/UI/UiButton.cs
+++ b/src/AcDream.App/UI/UiButton.cs
@@ -36,7 +36,9 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
private readonly ElementInfo _mediaInfo;
private readonly FaceSegment[] _faceSegments;
private readonly Func _resolve;
- private readonly HashSet _availableStates = new();
+ private readonly string[] _segmentMediaStates;
+ private string _faceMediaState = "";
+ private string? _lastMediaCommitState;
private readonly bool _hasCustomSelectionPair;
private IReadOnlyDictionary? _stateLabelColors;
private IReadOnlyDictionary? _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;
///
- /// Returns the File id for the current , falling back to
- /// the DirectState ("" key) if the named state is absent.
- /// Returns 0 if neither exists.
- /// Mirrors .
+ /// Retail's SetState media rule (UIElement::SetState @0x00464E70
+ /// tail, the m_media.m_num != 0 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 Normal_pressed 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
+ /// write so every commit path (the visual
+ /// state machine, TrySetRetailState, external assignments) is covered.
///
- 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;
+ }
+
+ ///
+ /// 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).
+ ///
+ 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);
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs
index 7dc5b92a..b2b52ff4 100644
--- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs
@@ -709,7 +709,16 @@ public sealed class CharacterCreationLiveDatTests
Assert.True(
spin.TrySetRetailState(UiButtonStateMachine.Highlight),
$"spin 0x{spinId:X8} must accept a Highlight state request.");
- Assert.Equal("Normal", spin.ActiveState);
+ // #416 media-rule port: the spins DO author a property-only
+ // Highlight StateDesc (the 0x1B/0x21 label style below), and
+ // retail's UIElement::SetState @0x00464E70 COMMITS any authored
+ // state — m_state becomes 6 — while the ARROW ART stays on
+ // Normal through the SetState media rule (@0x004651c0: an
+ // empty-media state never replaces the playing media). The old
+ // "Normal" pin here encoded the pre-port media-keyed gate that
+ // refused the commit outright; the state now commits and only
+ // the art no-ops, which is the exact retail split.
+ Assert.Equal("Highlight", spin.ActiveState);
// AP-222 CORRECTED + RESOLVED (Campaign CC gate round 1 Batch B):
// the art half of the "no-op" stays a genuine no-op (ActiveState
diff --git a/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs
index ba063304..4eb14762 100644
--- a/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs
@@ -164,6 +164,30 @@ public sealed class PowerbarLayoutProbeTests
DumpElement(strings, root!, 0);
}
+ /// 2026-08-17 #416 (stuck character-select rollover): dump the
+ /// character-management roster row template (0x21000004/0x100003A5) —
+ /// which states it authors, which of them carry media, and the default
+ /// state — so the hover-leave fix binds the authored truth.
+ [Fact]
+ public void ProbeCharacterSelectRowTemplate()
+ {
+ if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_POWERBAR") != "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);
+
+ ElementInfo? row = LayoutImporter.ImportInfos(dats, 0x21000004u, 0x100003A5u);
+ Assert.NotNull(row);
+ Console.WriteLine("[pbprobe] === char-select roster row 0x21000004/0x100003A5 ===");
+ DumpElement(strings, row!, 0);
+ }
+
private static ElementInfo? FindById(ElementInfo element, uint id)
{
if (element.Id == id) return element;
@@ -219,7 +243,8 @@ public sealed class PowerbarLayoutProbeTests
$"0x{pair.Key:X2}:{pair.Value.Kind}=0x{pair.Value.UnsignedValue:X}"));
Console.WriteLine(
$"[pbprobe] {indent} state 0x{stateId:X8} '{state.Name}'"
- + $" passToChildren={state.PassToChildren}{text} props[{properties}]");
+ + $" passToChildren={state.PassToChildren} mediaCount={state.MediaCount}"
+ + $"{text} props[{properties}]");
}
foreach (ElementInfo child in e.Children)
DumpElement(strings, child, depth + 1);
diff --git a/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs
index ac4d8f89..7a66e051 100644
--- a/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs
@@ -207,13 +207,98 @@ public class UiDatElementTests
}
[Fact]
- public void NumericStateBridge_MissingStatePreservesCurrentState()
+ public void NumericStateBridge_MissingStateCommitsBaseState()
{
+ // Retail UIElement::SetState @0x00464E70 coerces an UNAUTHORED state
+ // id to state 0 (the unnamed base state) and commits it — it never
+ // preserves the current state (#416; this test previously codified
+ // the pre-decomp misreading).
var info = new ElementInfo { DefaultStateName = "Normal" };
info.StateMedia["Normal"] = (0x06000001u, 1);
var element = new UiDatElement(info, _ => (0u, 0, 0));
- Assert.False(element.TrySetRetailState(RetailUiStateIds.ShowDetail));
- Assert.Equal("Normal", element.ActiveState);
+ Assert.True(element.TrySetRetailState(RetailUiStateIds.ShowDetail));
+ Assert.Equal("", element.ActiveState);
+ }
+
+ ///
+ /// #416 — the character-select roster row shape: the highlight-bar
+ /// children (0x10000481/82/83 in 0x21000004) author Normal_rollover
+ /// media but NO 'Normal' state, so the row's PassToChildren hover-leave
+ /// cascade commits an unauthored Normal on them. Retail's state-0 arm
+ /// clears the bar; the old refusal latched it highlighted forever.
+ ///
+ [Fact]
+ public void TrySetRetailState_UnauthoredNormal_ClearsRolloverMedia()
+ {
+ var info = new ElementInfo();
+ info.States[UiStateInfo.DirectStateId] =
+ new UiStateInfo { Id = UiStateInfo.DirectStateId };
+ info.States[2u] = new UiStateInfo { Id = 2u, Name = "Normal_rollover" };
+ info.StateMedia["Normal_rollover"] = (0x06005EB6u, 1);
+ var element = new UiDatElement(info, _ => (0u, 0, 0));
+
+ Assert.True(element.TrySetRetailState(2u));
+ Assert.Equal("Normal_rollover", element.ActiveState);
+
+ Assert.True(element.TrySetRetailState(UiButtonStateMachine.Normal));
+ Assert.Equal("", element.ActiveState);
+ Assert.Equal((0u, 0), element.ActiveMedia());
+ }
+
+ ///
+ /// The state-0 fallback must NOT honor a base-DirectState Invisible
+ /// (dat 0x3B) — that is the #408 construction-time class, gated
+ /// separately; retail's per-state Invisible honor applies to NAMED
+ /// authored states only.
+ ///
+ [Fact]
+ public void TrySetRetailState_UnauthoredStateFallback_DoesNotHonorBaseInvisible()
+ {
+ var info = new ElementInfo();
+ var baseState = new UiStateInfo { Id = UiStateInfo.DirectStateId };
+ baseState.Properties.Values[0x3Bu] = new UiPropertyValue
+ {
+ Kind = UiPropertyKind.Bool,
+ BoolValue = true,
+ };
+ info.States[UiStateInfo.DirectStateId] = baseState;
+ info.StateMedia["Normal_rollover"] = (0x06005EB6u, 1);
+ var element = new UiDatElement(info, _ => (0u, 0, 0));
+ Assert.True(element.Visible);
+
+ Assert.True(element.TrySetRetailState(UiButtonStateMachine.Normal));
+
+ Assert.Equal("", element.ActiveState);
+ Assert.True(element.Visible);
+ }
+
+ ///
+ /// Retail's state-0 arm cascades state 0 to children when the BASE
+ /// descriptor authors PassToChildren (m_desc.m_bPassToChildren,
+ /// @0x00464eca).
+ ///
+ [Fact]
+ public void TrySetRetailState_UnauthoredStateFallback_CascadesBasePerBaseDescriptor()
+ {
+ var childInfo = new ElementInfo();
+ childInfo.StateMedia[""] = (0x06000002u, 1);
+ childInfo.StateMedia["Normal_rollover"] = (0x06005EB6u, 1);
+ childInfo.States[2u] = new UiStateInfo { Id = 2u, Name = "Normal_rollover" };
+ var child = new UiDatElement(childInfo, _ => (0u, 0, 0));
+ Assert.True(child.TrySetRetailState(2u));
+
+ var parentInfo = new ElementInfo();
+ parentInfo.States[UiStateInfo.DirectStateId] = new UiStateInfo
+ {
+ Id = UiStateInfo.DirectStateId,
+ PassToChildren = true,
+ };
+ var parent = new UiDatElement(parentInfo, _ => (0u, 0, 0));
+ parent.AddChild(child);
+
+ Assert.True(parent.TrySetRetailState(UiButtonStateMachine.Normal));
+
+ Assert.Equal("", child.ActiveState);
}
}
diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs
index 48172af5..f124991f 100644
--- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs
@@ -172,17 +172,24 @@ public class UiButtonTests
[Fact]
public void PropertyOnlyPressedState_PreservesDrawableNormalFace()
{
+ // #416 media-rule port: retail COMMITS the authored empty-media
+ // Normal_pressed (UIElement::SetState @0x00464E70 commits any
+ // authored state) while the FACE keeps the Normal art (the
+ // @0x004651c0 media rule: an empty media array never replaces the
+ // playing media) — the press must not blank the button.
var info = ButtonInfo("Normal", "Highlight");
info.States[UiButtonStateMachine.NormalPressed] = new UiStateInfo
{
Id = UiButtonStateMachine.NormalPressed,
Name = "Normal_pressed",
};
- var b = CreateButton(info);
+ var b = CreateDrawableButton(info);
+ Assert.Equal(1u, DrawnFaceFile(b)); // ButtonInfo assigns "Normal" file 1
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
- Assert.Equal("Normal", b.ActiveState);
+ Assert.Equal("Normal_pressed", b.ActiveState);
+ Assert.Equal(1u, DrawnFaceFile(b));
}
[Fact]
@@ -347,21 +354,27 @@ public class UiButtonTests
}
///
- /// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): per-state label
- /// color/outline reacts to the REQUESTED retail state id even when the
- /// standard art-availability gate never lets ActiveState reach it — the
- /// Appearance spins' exact shape (their arrow face segments carry no
- /// Highlight media at all, so ActiveState is permanently stuck at
- /// "Normal", but the label text must still recolor). Live-DAT-measured
- /// values: Normal (218,167,85), Highlight (255,221,131), outline
- /// off -> on.
+ /// AP-222 / GF-11b, re-derived at the #416 media-rule port: the
+ /// Appearance spins author a PROPERTY-ONLY Highlight StateDesc (the
+ /// 0x1B/0x21 label style, no media). Retail's machine gate
+ /// (UIElement_Button::UpdateState_ @0x00471CF0) admits any AUTHORED
+ /// state, and UIElement::SetState @0x00464E70 then commits it — the
+ /// label recolors — while the ARROW ART stays on Normal through the
+ /// SetState media rule (@0x004651c0: an empty media array never
+ /// replaces the playing media). Live-DAT-measured values: Normal
+ /// (218,167,85), Highlight (255,221,131), outline off -> on.
///
[Fact]
- public void PerStateLabelStyle_AppliesEvenWhenActiveStateCannotReachIt()
+ public void PerStateLabelStyle_PropertyOnlyHighlight_CommitsAndKeepsFace()
{
- var info = ButtonInfo("Normal"); // no Highlight media at all
+ var info = ButtonInfo("Normal"); // Highlight authors NO media...
+ info.States[UiButtonStateMachine.Highlight] = new UiStateInfo
+ {
+ Id = UiButtonStateMachine.Highlight,
+ Name = "Highlight", // ...but IS authored (property-only)
+ };
AddBoolProperty(info, 0x0Bu, true); // ToggleBehavior
- var b = CreateButton(info);
+ var b = CreateDrawableButton(info);
b.Label = "Hair Style";
b.LabelColor = new System.Numerics.Vector4(1f, 1f, 1f, 1f);
@@ -379,15 +392,16 @@ public class UiButtonTests
Assert.Equal(colors[UiButtonStateMachine.Normal], b.LabelColor);
Assert.False(b.Outline);
+ Assert.Equal(1u, DrawnFaceFile(b)); // "Normal" art (file 1)
b.Selected = true;
- // The art stays "Normal" (no Highlight media exists to commit to) —
- // this is the exact AP-222 no-op the standard gate always produced —
- // but the label color/outline must still reach the Highlight values.
- Assert.Equal("Normal", b.ActiveState);
+ // The authored property-only Highlight COMMITS (retail SetState) and
+ // the label recolors; the face keeps the Normal art (media rule).
+ Assert.Equal("Highlight", b.ActiveState);
Assert.Equal(colors[UiButtonStateMachine.Highlight], b.LabelColor);
Assert.True(b.Outline);
+ Assert.Equal(1u, DrawnFaceFile(b));
}
///
@@ -730,4 +744,35 @@ public class UiButtonTests
private static UiButton CreateButton(ElementInfo info)
=> new(info, NoTex) { Width = info.Width, Height = info.Height };
+
+ // ── #416 media-rule draw harness ─────────────────────────────────────
+
+ private sealed class NullGpuFrameSource
+ : AcDream.App.Rendering.ICurrentGpuFrameSource
+ {
+ public AcDream.App.Rendering.Gpu.IGpuFrame? CurrentFrame => null;
+ }
+
+ /// Identity resolve (texture handle == file id) so the drawn
+ /// face file is directly observable through the recording renderer.
+ private static UiButton CreateDrawableButton(ElementInfo info)
+ => new(info, static file => (file, 8, 8))
+ {
+ Width = info.Width,
+ Height = info.Height,
+ };
+
+ private static uint DrawnFaceFile(UiButton button)
+ {
+ var device = new AcDream.App.Tests.Rendering.Gpu.RecordingGpuDevice();
+ var renderer = new AcDream.App.Rendering.TextRenderer(
+ device, new NullGpuFrameSource(), "unused");
+ renderer.Begin(new System.Numerics.Vector2(200f, 200f));
+ var ctx = new UiRenderContext(
+ renderer, new System.Numerics.Vector2(200f, 200f));
+ button.DrawSelfAndChildren(ctx);
+ return renderer.DebugSpriteSegmentVerts.Count == 0
+ ? 0u
+ : renderer.DebugSpriteSegmentVerts[0].Item1;
+ }
}