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; + } }