Lands Batch G's two STOPPED items, making the real palette-color swatch wheel visually live instead of inert: - UiButton and UiDatElement gain a per-instance Tint property threaded into every existing DrawSprite call (defaults to Vector4.One, so every pre-existing button/element is byte-identical unless a caller sets a non-identity tint). - CharacterCreationAppearancePage now sets Tint directly on each color swatch button and the GradCircle element, replacing the Batch G flat-fill ChargenSwatchColorTile overlay outright — an opaque rectangle drawn on top can never reproduce retail's actual SurfaceWindow::BlitAndColor(..., Blit_Multiply, color) multiply blend, only a genuine per-instance sprite tint can, so the overlay approach is deleted rather than layered under the new mechanism. - CharacterCreationUiController and RetailUiRuntime grow pass-through properties (AppearancePalSetSource/AppearanceClothingTableSource/ AppearancePaletteColorSource) mirroring the existing PreviewControl seam, so LivePresentationComposition can wire a DAT-backed ChargenAppearanceCatalog into the Appearance page (wiring itself lands with the Group 3 commit, since it shares a file with an unrelated F16 fix). Register: AP-216/AP-217 RETIRED (161 -> now further reduced in later commits) — both rows' remaining gaps are closed, not merely narrowed. CharacterCreationAppearancePageSwatchColorTests updated for the new Tint-based assertions (two pre-existing assertions were carried over incorrectly from the old overlay-visibility model and are corrected). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
320 lines
15 KiB
C#
320 lines
15 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))
|
||
return false;
|
||
ActiveState = stateName;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|