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
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -164,6 +164,30 @@ public sealed class PowerbarLayoutProbeTests
|
|||
DumpElement(strings, root!, 0);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
[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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #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.
|
||||
/// </summary>
|
||||
[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());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's state-0 arm cascades state 0 to children when the BASE
|
||||
/// descriptor authors PassToChildren (m_desc.m_bPassToChildren,
|
||||
/// @0x00464eca).
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>Identity resolve (texture handle == file id) so the drawn
|
||||
/// face file is directly observable through the recording renderer.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue