#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>
376 lines
18 KiB
C#
376 lines
18 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|