fix #416 #415: the retail button state/media machine — roster hover highlight clears; probe wait verbs bind without an artifact dir

#416 (char-select roster highlight never cleared on hover-leave): three
decomp-grounded mechanisms replace the media-keyed _availableStates
approximation.
- UIElement_Button::UpdateState_ @0x00471CF0: the button machine commits
  ONLY states authored on the button's OWN ElementDesc (AccessStateDesc
  gate); unauthored requests no-op, preserving custom semantic states.
- UIElement::SetState @0x00464E70: an unauthored state id is coerced to
  state 0 (the unnamed base state) and committed — ported into
  UiDatElement.TrySetRetailState with the base-descriptor PassToChildren
  cascade arm.
- The SetState media rule @0x004651c0: a committed state replaces the
  playing media ONLY when its media array is non-empty. UiButton now keeps
  per-face-segment media states under that rule (segments model retail's
  PassToChildren children), and LayoutImporter records the raw MediaCount
  including the File=0 draw-nothing images the drawable filter drops —
  the roster bar children's base state is exactly such an image, and it is
  what clears the bar.
The row template truth (probe, installed DAT): the row authors EMPTY
Normal/rollover/Highlight descriptors with PassToChildren; the three bar
children author rollover/Highlight media, NO Normal state, and a File=0
base image. An empty-media Normal_pressed still never blanks a Normal-art
button (the media rule keeps the previous art — the exact behavior the
old gate approximated), and the Appearance spins' property-only Highlight
now genuinely commits: label recolors, arrow art lingers — the retail
split AP-222 approximated with a requested-keyed label hack, now retired.
Live-verified at char select: hover +alex shows the grey bar, moving off
clears it, the selected row keeps its amber bar.

#415 (probe wait world-* verbs dead): the filed snapshot-reset diagnosis
was wrong — the automation bridge simply never bound without
ACDREAM_AUTOMATION_ARTIFACT_DIR. A facts-only
WorldRevealFactsAutomationRuntime now binds whenever the retained UI
exists; checkpoint/screenshot verbs still require the artifact directory
and now report that instead of a generic timeout.

App tests 5568/3 skips, Runtime 1756/0, UI.Abstractions 926/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 16:25:36 +02:00
parent 7aa08045d8
commit 91c1962b0d
11 changed files with 469 additions and 86 deletions

View file

@ -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

View file

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

View file

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

View file

@ -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 -&gt; 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 -&gt; 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;
}
}