acdream/src/AcDream.App/UI/Layout/UiDatElement.cs
Erik 91c1962b0d 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>
2026-08-17 16:25:36 +02:00

376 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Generic dat element: draws its active state's media by DrawMode (Normal=tile,
/// Alphablend/Overlay=blended overlay). The fallback renderer for every element type
/// without a dedicated behavioral widget (chrome corners/edges, drag bars, resize grips);
/// faithful because retail's base element render is exactly "stamp the media per draw-mode".
///
/// <para>
/// For Plan 1, all observed draw modes produce the same alpha-blended tiled quad — the
/// sprite shader already alpha-blends, so no per-mode branch is needed here. The named
/// constants document the real enum for Plan 2.
/// </para>
///
/// <para>
/// DrawModeType (DatReaderWriter.Enums), stored as int in <see cref="ElementInfo"/> to
/// keep this dat-free. See docs/research/2026-06-15-layoutdesc-format.md §6:
/// <c>Undefined=0, Normal=1, Overlay=2, Alphablend=3</c>. There is no Stretch mode.
/// </para>
///
/// <para>
/// Tiling uses UV-repeat on BOTH axes (<c>Width/tw</c>, <c>Height/th</c>) so vertical
/// chrome edges (e.g. a 5×10 sprite drawn over a 5×48 rect) tile vertically too.
/// <see cref="AcDream.App.Rendering.TextureCache.UploadRgba8"/> sets
/// <c>GL_REPEAT</c> on both S and T, so vertical tiling is always active.
/// </para>
///
/// <para>
/// <b>OP2 rework (2026-08-11):</b> unsealed so behavioral Type-8/Type-5 widgets
/// (<see cref="UiTabPanel"/>, <see cref="UiTemplateListBox"/>) can subclass it and stay
/// DORMANT by default — every element of those two dat Types that reaches
/// <see cref="DatWidgetFactory"/> gets this exact media-draw / <c>ClickThrough</c> /
/// <see cref="IUiDatStateful"/> behavior unless a controller explicitly opts a specific
/// instance into its behavioral mechanism. This is what makes the unconditional Type-8/
/// Type-5 factory mappings safe for the pre-existing shipped panels the OP2 REJECT
/// findings named (`docs/research/2026-08-11-op2-review-blast.md`,
/// `docs/research/2026-08-11-op2-review-mechanism.md`).
/// </para>
/// </summary>
public class UiDatElement : UiElement, IUiDatStateful
{
// DrawModeType enum values from DatReaderWriter.Enums.
// See docs/research/2026-06-15-layoutdesc-format.md §6.
#pragma warning disable IDE0051 // private constants kept for documentation / Plan 2
private const int DrawUndefined = 0;
private const int DrawNormal = 1;
private const int DrawOverlay = 2;
private const int DrawAlphablend = 3;
#pragma warning restore IDE0051
/// <summary>Protected so subclasses (e.g. <see cref="UiTabPanel"/>,
/// <see cref="UiTemplateListBox"/>) can read authored state/media for their own
/// mechanism-specific fields without a second copy of the merged snapshot.</summary>
protected readonly ElementInfo Info;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
/// <summary>The dat element id from <see cref="ElementInfo.Id"/>. Exposed so controllers
/// can identify which logical element a UiDatElement represents when walking subtrees
/// (e.g. footer state groups that appear once per tab page but share the same dat id).</summary>
public uint ElementId => Info.Id;
/// <summary>Which state name to render. <c>""</c> = the unnamed DirectState.
/// Falls back to DirectState if the named state is absent.</summary>
public string ActiveState { get; set; } = "";
public uint ActiveRetailStateId
{
get
{
if (string.IsNullOrEmpty(ActiveState))
return UiStateInfo.DirectStateId;
foreach (var (id, state) in Info.States)
if (string.Equals(state.Name, ActiveState, StringComparison.Ordinal))
return id;
return UiButtonStateMachine.TryStateId(ActiveState, out uint standard)
? standard
: RetailUiStateIds.TryStateId(ActiveState, out uint custom) ? custom : 0u;
}
}
public override string ActiveCursorStateName => ActiveState;
public bool TrySetRetailState(uint stateId)
{
UiStateInfo? selectedState = null;
if (stateId == UiStateInfo.DirectStateId)
{
if (!Info.States.TryGetValue(stateId, out selectedState)
&& !Info.StateMedia.ContainsKey(""))
return false;
ActiveState = "";
}
else if (Info.States.TryGetValue(stateId, out selectedState))
{
ActiveState = selectedState.Name;
}
else
{
string stateName = UiButtonStateMachine.StateName(stateId);
if (string.IsNullOrEmpty(stateName))
stateName = RetailUiStateIds.StateName(stateId);
if (string.IsNullOrEmpty(stateName) || !Info.StateMedia.ContainsKey(stateName))
{
// 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;
}
// Per-state Invisible (dat property 0x3B): retail's SetState applies
// the committed state's properties through UIElement::OnSetAttribute,
// whose case 8 (@0x00462d80, property id 0x33 + 8 = 0x3B) is
// `SetVisible(value == 0)` — an authored per-STATE true hides the
// element for that state. First consumer (2026-08-17 morning gate
// finding 3): the map town-hotspot template's highlight child
// (0x100001F1 in 0x21000026, base 0x100002B7@0x21000042 — the green
// 0x06004CC9 frame) authors Normal={0x3B:true} /
// Normal_rollover={0x3B:false}, i.e. hidden at rest, shown on
// rollover.
//
// SCOPED TO NAMED STATES ONLY: a 0x3B authored in the unnamed
// DirectState is the CONSTRUCTION-time "authored invisible" class
// (1,083 elements client-wide — docs/ISSUES.md #408, its own
// separately-gated general-honor item; ElementReader.Invisible/GF-13
// captures it and only chargen's scoped walk acts on it, register
// AP-230). Honoring it here would un-gate #408 through the back
// door: LayoutImporter.BuildWidget's post-children state reapply
// calls TrySetRetailState(DirectStateId) on every built widget, so
// a DirectState honor would hide all 1,083 at import (measured
// same-round: 10 combat-layout elements incl. 0x10000454 went
// un-hit-testable, breaking the spell-favorite drag tests). The
// NAMED-state flip below is a live visibility state machine that
// cannot work at all without the honor — that is this port's line.
if (stateId != UiStateInfo.DirectStateId
&& selectedState is not null
&& selectedState.Properties.TryGetValue(0x3Bu, out var invisibleProp)
&& invisibleProp.Kind == UiPropertyKind.Bool)
Visible = !invisibleProp.BoolValue;
if (selectedState?.PassToChildren == true)
{
foreach (UiElement child in Children)
if (child is IUiDatStateful stateful)
stateful.TrySetRetailState(stateId);
}
return true;
}
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
public UiDatElement(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
{
Info = info;
_resolve = resolve;
ClickThrough = true; // generic decoration; behavioral widgets opt back in
// Pick the initial active state: retail applies DefaultState when set; falls back
// to "Normal" when the element has a Normal-state sprite (retail's implicit default
// for stateful elements like tabs and buttons); else the unnamed DirectState ("").
if (!string.IsNullOrEmpty(info.DefaultStateName))
ActiveState = info.DefaultStateName;
else if (info.StateMedia.ContainsKey("Normal"))
ActiveState = "Normal";
// else ActiveState stays "" (DirectState)
// Outline 0x21 / OutlineColor 0x22 from the effective-default state, mirroring
// DatWidgetFactory.BuildText's seed of UiText (round-5 review S2).
Outline = info.Outline;
if (info.OutlineColor.HasValue)
OutlineColor = info.OutlineColor.Value;
}
/// <summary>
/// Returns the (File, DrawMode) for the current <see cref="ActiveState"/>,
/// falling back to the DirectState (<c>""</c> key) if the named state is absent.
/// Returns (0, 0) if neither exists.
/// </summary>
// exposed for unit testing
public (uint File, int DrawMode) ActiveMedia()
=> Info.StateMedia.TryGetValue(ActiveState, out var m) ? m
: Info.StateMedia.TryGetValue("", out var d) ? d
: (0u, 0);
/// <summary>Optional click handler. Set by a controller for interactive dat
/// elements (e.g. the chat Send / max-min buttons). Requires
/// <see cref="UiElement.ClickThrough"/> = false to receive click events.</summary>
public Action? OnClick { get; set; }
public Action<int, int>? OnClickAt { get; set; }
public override bool HandlesClick => OnClick is not null || OnClickAt is not null;
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click && (OnClick is not null || OnClickAt is not null))
{
OnClick?.Invoke();
OnClickAt?.Invoke(e.Data1, e.Data2);
return true;
}
return false;
}
/// <summary>Optional centered text label drawn over the sprite (e.g. the "Send"
/// button face whose dat sprite is a blank frame). Null = sprite only.</summary>
public string? Label { get; set; }
/// <summary>Dat font for <see cref="Label"/>. Required for the label to draw.</summary>
public UiDatFont? LabelFont { get; set; }
/// <summary>Label color (default white).</summary>
public Vector4 LabelColor { get; set; } = Vector4.One;
/// <summary>
/// Campaign CC gate round 1 closeout (Group 1, R2-5): per-instance
/// multiplicative sprite tint, threaded into both <see cref="UiRenderContext.DrawSprite"/>
/// calls this class makes (the runtime-image path and the ordinary
/// authored-media path) — same shape and same default-identity
/// no-op-for-existing-callers guarantee as <see cref="UiButton.Tint"/>.
/// </summary>
public Vector4 Tint { get; set; } = Vector4.One;
/// <summary>Retail LayoutDesc property <c>0x21</c> (two-pass glyph outline,
/// <c>UIElement_Text::SetOutline @0x0046a81c</c>). Seeded in the ctor from the
/// element's effective-default state, same as <see cref="UiText.Outline"/>
/// (round-5 review S2 — per-STATE switching is AP-192).</summary>
public bool Outline { get; set; }
/// <summary>Retail LayoutDesc property <c>0x22</c> (<c>m_curOutlineColor</c>,
/// ctor default black). Only meaningful when <see cref="Outline"/> is true.</summary>
public Vector4 OutlineColor { get; set; } = UiRenderContext.DefaultOutlineColor;
/// <summary>
/// Controls only this element's authored media. Descendants still draw.
/// Layered retail composites use this to place a template's chrome behind a
/// viewport while retaining its text descendants above that viewport.
/// </summary>
public bool MediaVisible { get; set; } = true;
/// <summary>
/// Runtime image installed on this region in place of its authored state media.
/// A non-null value mirrors retail <c>UIRegion::ClearImage</c> followed by
/// <c>UIRegion::SetImage</c>; zero deliberately leaves the region image-less.
/// The image remains this element's own media, so authored descendants retain
/// their normal foreground relationship.
/// </summary>
public uint? RuntimeImageTexture { get; set; }
/// <summary>
/// Retail background-blit ground truth (Campaign LA gate round 2, register
/// AD-98). Every element draws its own media with the native-pixel TILE
/// formula below — retail has no per-element stretch, and neither do we.
///
/// <para>
/// <b>Campaign LA gate round 2 (issue found in the live client: the LA8
/// character-select background repeated across the window instead of scaling
/// with it).</b> Retail's generic UI sprite blit —
/// <c>Graphic::Draw</c> (acclient 0x00693b20) dispatching to
/// <c>Graphic::PutImage</c> (0x00693a30) for an exact/undersized destination, or a
/// modulo-wrapped tile loop otherwise — has exactly two behaviors, copy or tile;
/// it can never scale a source image up to a larger destination. This is confirmed
/// against two candidate "draw-mode" fields that could have carried a stretch bit
/// and don't: <c>BlitMode</c> (acclient.h ~line 3135 — Blit_Normal/3Alpha/4Alpha/
/// Colorize/Multiply/Screen/Grayscale/NOP are all COLOR-BLEND selectors) and
/// <c>MD_Data_Image::m_drawMode</c>/<c>DrawModeType</c> (Undefined/Normal/Overlay/
/// Alphablend — also a blend selector; the "Normal → tile" reading in
/// <c>docs/research/2026-06-15-layoutdesc-format.md</c> §6 cited
/// <c>ImgTex::TileCSI</c> (0x0053e740), but that function is exclusively called from
/// <c>TexMerge::CopyAndTile</c>/<c>ImgTex::CopyCSI</c> for LAND-SURFACE terrain
/// texture compositing (<c>TerrainTex</c>) — never from the UI element system; the
/// citation was a coincidental name match, not the real call site).
/// </para>
///
/// <para>
/// The LA8 root itself (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0
/// ("no anchor" — confirmed against the installed DAT via
/// <c>CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf</c>),
/// so retail's own <c>UIElement::UpdateForParentSizeChange</c> (0x00462640) never
/// touches this element's size at all — it stays a fixed 800x600 rect. The only way
/// retail's whole pre-world "flow" scene (background AND buttons AND listbox
/// together — "the background scales with the root") can still fill an arbitrary
/// window resolution edge-to-edge, with the generic sprite blit only ever able to
/// copy-or-tile, is that these screens render into a fixed, authored-size (800x600)
/// target and the WHOLE FRAME is stretched once at presentation — a step entirely
/// outside the UIRegion/<c>Graphic::Draw</c> sprite system.
/// </para>
///
/// <para>
/// acdream's equivalent of that present-time stretch is
/// <see cref="AcDream.App.UI.UiRoot.FixedCanvasSize"/>: while a fixed-canvas
/// screen (char select) is active, the WHOLE retained tree — this tile draw
/// included — is scaled uniformly at the renderer's quad chokepoint, with the
/// inverse applied to mouse input. Elements therefore keep their authored
/// canvas-space sizes here, and the tile formula stays exactly retail's:
/// inside the authored canvas an element never exceeds its media's native
/// span unless retail itself tiled it.
/// </para>
/// </summary>
protected override void OnDraw(UiRenderContext ctx)
{
if (MediaVisible && RuntimeImageTexture is uint runtimeTexture)
{
if (runtimeTexture != 0u)
{
ctx.DrawSprite(
runtimeTexture,
0f,
0f,
Width,
Height,
0f,
0f,
1f,
1f,
Tint);
}
DrawLabel(ctx);
return;
}
var (file, _) = ActiveMedia();
if (MediaVisible && file != 0)
{
var (tex, tw, th) = _resolve(file);
if (tex != 0 && tw != 0 && th != 0)
{
// TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped
// UI texture) — retail's Graphic::Draw/Graphic::PutImage
// (0x00693b20/0x00693a30) copy-or-tile blit; NOT ImgTex::TileCSI,
// which is land-surface-only (corrected citation, see the class
// doc). Overlay/Alphablend use the same blit (the sprite shader
// already alpha-blends). No Stretch mode exists in DrawModeType;
// whole-canvas stretching happens at UiRoot.FixedCanvasSize.
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Tint);
}
}
DrawLabel(ctx);
}
private void DrawLabel(UiRenderContext ctx)
{
// Centered text label over the sprite (retail draws button captions as text;
// their dat sprites are blank frames).
if (Label is { Length: > 0 } label && LabelFont is { } lf)
{
float tx = (Width - lf.MeasureWidth(label)) * 0.5f;
float ty = (Height - lf.LineHeight) * 0.5f;
ctx.DrawStringDat(lf, label, tx, ty, LabelColor, Outline, OutlineColor);
}
}
}