acdream/src/AcDream.App/UI/Layout/ElementReader.cs
Erik 6e0be4bd34 feat(ui): importer carries dat FontColor (0x1B) onto text widgets; character colors from dat where present
LayoutImporter.ReadState now reads Properties 0x1B (ColorBaseProperty, ARGB bytes) and stores the normalized Vector4 in ElementInfo.FontColor (nullable). ElementReader.Merge propagates it with the same non-null-derived-wins rule as FontDid and HJustify. DatWidgetFactory.BuildText seeds UiText.DefaultColor from FontColor when present.

Diagnosis for LayoutDesc 0x2100002E: ALL 12 header and footer text elements carry NO dat color. Every color is runtime set by CharacterStatController. Comments added at each callsite. No hardcoded colors deleted.

Tests added: 3 ElementReader FontColor Merge + 3 DatWidgetFactory DefaultColor. 702 passed, 0 failed. Screenshots: character window, vitals, toolbar all unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:15:55 +02:00

223 lines
12 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.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Horizontal text justification read from dat property 0x14 (UIElement HorizontalJustification).
/// Values match the retail enum: 0=Left, 1=Center, 3/5=Right.
/// </summary>
public enum HJustify : byte { Left = 0, Center = 1, Right = 2 }
/// <summary>
/// Vertical text justification read from dat property 0x15 (UIElement VerticalJustification).
/// Values: 2=Top, 4=Bottom; absent/other = Center.
/// </summary>
public enum VJustify : byte { Top = 0, Center = 1, Bottom = 2 }
/// <summary>
/// GL-free, dat-free snapshot of a resolved layout element.
/// Populated by the LayoutDesc importer from <c>DatReaderWriter.ElementDesc</c>
/// after inheritance is applied. The pure transforms on <see cref="ElementReader"/>
/// operate on this type so they can be unit-tested without the dats or OpenGL.
///
/// IMPORTANT: Tasks 36 depend on this shape exactly. Do not add members without
/// updating the plan spec and downstream consumers.
/// </summary>
public sealed class ElementInfo
{
/// <summary>Dat element id (e.g. <c>0x100000E6</c>).</summary>
public uint Id;
/// <summary>
/// Raw element class id as a uint.
/// Game-specific ids like <c>0x1000004D</c> (gmVitalsUI root) and <c>0x10000009</c>
/// overflow <c>int</c> when treated as signed, so this stays <c>uint</c>.
/// Known values: 0=text, 2=dragbar, 3=container/chrome, 7=meter,
/// 9=resize-grip, 12=style-prototype (skip), 0x10000009/0x1000004D=window root.
/// </summary>
public uint Type;
/// <summary>Position and size within the parent, in pixels (cast from dat uint fields).</summary>
public float X, Y, Width, Height;
/// <summary>
/// Raw edge-anchor flag values from the dat (<c>LeftEdge</c>, <c>TopEdge</c>,
/// <c>RightEdge</c>, <c>BottomEdge</c> fields of <c>ElementDesc</c>).
/// Values 04; map to <see cref="AnchorEdges"/> bit-flags via
/// <see cref="ElementReader.ToAnchors"/>.
/// </summary>
public uint Left, Top, Right, Bottom;
/// <summary>Draw order within the parent (lower = drawn first / behind).</summary>
public uint ReadOrder;
/// <summary>Layer level from the dat (<c>ElementDesc.ZLevel</c>). Higher = drawn further
/// BACK (a full-window backdrop at ZLevel 100 sits behind ZLevel-0 panels). The factory
/// folds it into <see cref="UiElement.ZOrder"/> so ZLevel dominates and ReadOrder is the
/// within-layer tiebreaker. Issue #145 (vitals are all ZLevel 0, so they're unaffected).</summary>
public uint ZLevel;
/// <summary>
/// Font dat object id inherited from the base element's <c>Properties[0x1A]</c>
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>). 0 = none / not inherited.
/// </summary>
public uint FontDid;
/// <summary>
/// Horizontal text justification from dat <c>Properties[0x14]</c>
/// (<c>EnumBaseProperty</c>: 0=Left, 1=Center, 3/5=Right).
/// Default is <see cref="HJustify.Center"/> to preserve existing behavior where
/// controllers set <c>Centered=true</c> and no property was read.
/// </summary>
public HJustify HJustify = HJustify.Center;
/// <summary>
/// Vertical text justification from dat <c>Properties[0x15]</c>
/// (<c>EnumBaseProperty</c>: 2=Top, 4=Bottom; absent/other = Center).
/// Default is <see cref="VJustify.Center"/> to preserve existing behavior.
/// </summary>
public VJustify VJustify = VJustify.Center;
/// <summary>
/// Font color from dat <c>Properties[0x1B]</c> (<c>ColorBaseProperty</c>, ARGB bytes).
/// Null when the dat carries no color for this element; the factory then leaves the
/// widget at its default white (<see cref="System.Numerics.Vector4.One"/>).
/// Propagated in <see cref="ElementReader.Merge"/> with the same "non-null derived wins"
/// rule used for <see cref="FontDid"/> and <see cref="HJustify"/>.
/// </summary>
public Vector4? FontColor;
/// <summary>
/// Sprite per state: state name → (RenderSurface file id, DrawMode int).
/// The <c>""</c> key represents the unnamed DirectState (<c>ElementDesc.StateDesc</c>).
/// Named states use the <c>UIStateId.ToString()</c> value as the key
/// (e.g. <c>"HideDetail"</c>, <c>"ShowDetail"</c>).
/// </summary>
public Dictionary<string, (uint File, int DrawMode)> StateMedia = new();
/// <summary>
/// The element's initial active state name, taken from <c>ElementDesc.DefaultState.ToString()</c>.
/// Normalized to <c>""</c> when the dat carries Undef/Undefined/0 (no default set).
/// Used by <see cref="UiDatElement"/> to pick which state's sprite to render initially.
/// Examples: <c>"Normal"</c> (Send button), <c>"Minimized"</c> (max/min button), <c>""</c> (DirectState).
/// </summary>
public string DefaultStateName = "";
/// <summary>
/// Resolved child elements (populated by the importer in Task 5).
/// Children come from the derived element's own tree, not the base element's.
/// </summary>
public List<ElementInfo> Children = new();
}
/// <summary>
/// Pure, GL-free, dat-free transforms for the LayoutDesc importer.
/// All methods are static and operate on <see cref="ElementInfo"/> POCOs.
/// No OpenGL, no DatReaderWriter types, no rendering dependencies beyond
/// the <see cref="AnchorEdges"/> bit-flag enum from <c>AcDream.App.UI</c>.
/// </summary>
public static class ElementReader
{
/// <summary>Edge-anchor flags → AnchorEdges, per retail UIElement::UpdateForParentSizeChange
/// @0x00462640. The far-axis fields drive stretch: RightEdge==1 ⇒ the right edge tracks the
/// parent's right edge (stretch); LeftEdge==2 ⇒ a fixed-width element's left tracks the right
/// edge (it moves right). ==4 (not present in the vitals layout) = both-sides stretch; ==3 =
/// centered (no edge anchor → falls back to pin-top-left). This is the INVERSE of the earlier
/// format-doc §4 reading, which was wrong (it made every piece fixed-width).</summary>
/// <param name="left">LeftEdge dat field value (04).</param>
/// <param name="top">TopEdge dat field value (04).</param>
/// <param name="right">RightEdge dat field value (04).</param>
/// <param name="bottom">BottomEdge dat field value (04).</param>
public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom)
{
var a = AnchorEdges.None;
if (left == 1 || left == 4) a |= AnchorEdges.Left;
if (right == 1 || right == 4 || left == 2) a |= AnchorEdges.Right;
if (top == 1 || top == 4) a |= AnchorEdges.Top;
if (bottom == 1 || bottom == 4 || top == 2) a |= AnchorEdges.Bottom;
if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left
return a;
}
/// <summary>
/// Merges a base element snapshot with a derived element snapshot, mirroring
/// the <c>BaseElement</c> / <c>BaseLayoutId</c> inheritance chain in the dat.
///
/// <para>
/// Rules:
/// <list type="bullet">
/// <item><description>
/// Scalar fields (<see cref="ElementInfo.Id"/>, <see cref="ElementInfo.Type"/>,
/// <see cref="ElementInfo.Width"/>, <see cref="ElementInfo.Height"/>,
/// <see cref="ElementInfo.FontDid"/>): derived wins if non-zero; otherwise
/// inherited from base.
/// </description></item>
/// <item><description>
/// Position (<see cref="ElementInfo.X"/>, <see cref="ElementInfo.Y"/>) and
/// edge flags (<see cref="ElementInfo.Left"/> etc.) and
/// <see cref="ElementInfo.ReadOrder"/>: always taken from the derived element
/// (derived placement, not the base prototype's geometry).
/// </description></item>
/// <item><description>
/// <see cref="ElementInfo.StateMedia"/>: base entries are the default; derived
/// entries override (or add) per state name key.
/// </description></item>
/// <item><description>
/// <see cref="ElementInfo.Children"/>: come from the derived element's own tree only.
/// </description></item>
/// </list>
/// </para>
/// </summary>
public static ElementInfo Merge(ElementInfo base_, ElementInfo derived)
{
var m = new ElementInfo
{
Id = derived.Id != 0 ? derived.Id : base_.Id,
// Type: derived wins if non-zero; Type 0 (text element per format §8) inherits the base's Type.
// For a text element whose base prototype is Type 12 (style prototype), this yields Type 12 —
// which DatWidgetFactory skips (returns null). That is intentional for Plan 1: vitals text
// numbers render via UiMeter.Label bound by VitalsController, not a dat text node.
// A Plan-2 standalone text element would need a type-preserving path (e.g. float? nullable
// Width/Height, or explicit handling of Type 0 before the merge).
Type = derived.Type != 0 ? derived.Type : base_.Type,
X = derived.X,
Y = derived.Y,
// NOTE: 0 is the "not set, inherit from base" sentinel for Width/Height. This
// diverges from the format doc §12 rule 2 ("derived W/H win even if zero") but is
// indistinguishable for Plan 1 (all base elements are zero-size Type-12 prototypes).
// If a real zero-size derived element ever needs to override a non-zero base in
// Plan 2, switch Width/Height to float? + null-coalescing (and update Tasks 3-5).
Width = derived.Width != 0 ? derived.Width : base_.Width,
Height = derived.Height != 0 ? derived.Height : base_.Height,
Left = derived.Left,
Top = derived.Top,
Right = derived.Right,
Bottom = derived.Bottom,
ReadOrder = derived.ReadOrder,
ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel,
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
// HJustify/VJustify: derived wins when it carries an explicit non-Center value
// (the dat property was present and read); otherwise inherit the base prototype's value.
// Center is the default (= "not set by this element") so Center-derived never overrides
// a non-Center base — matching the FontDid "non-zero wins" convention.
HJustify = derived.HJustify != HJustify.Center ? derived.HJustify : base_.HJustify,
VJustify = derived.VJustify != VJustify.Center ? derived.VJustify : base_.VJustify,
// FontColor: derived wins when it has an explicit (non-null) color; otherwise inherit the base.
// Null means "dat carried no 0x1B property" — so null-derived does NOT override a non-null base.
FontColor = derived.FontColor ?? base_.FontColor,
// DefaultStateName: derived wins if set; otherwise inherit the base's default.
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
// Children come from the derived element's own tree, not the base prototype's.
// Defensive copy: prevent a later mutation of either the merged result or the input
// from corrupting the other. Safe for the Task-5 flow (derived.Children is fully
// populated by the recursive importer BEFORE Merge is called and never mutated after).
Children = new List<ElementInfo>(derived.Children),
};
// Start with base StateMedia as defaults, then let derived entries override.
m.StateMedia = new Dictionary<string, (uint, int)>(base_.StateMedia);
foreach (var kv in derived.StateMedia)
m.StateMedia[kv.Key] = kv.Value;
return m;
}
}