feat(ui): port retained widget foundations

This commit is contained in:
Erik 2026-07-10 17:55:41 +02:00
parent 44f9ec13d9
commit d825572e31
44 changed files with 84813 additions and 292 deletions

View file

@ -0,0 +1,63 @@
namespace AcDream.App.UI;
/// <summary>
/// Narrow numeric-state bridge for widgets imported from retail LayoutDesc data.
/// Controllers use retail state ids without knowing how names/media are stored.
/// </summary>
public interface IUiDatStateful
{
uint ActiveRetailStateId { get; }
bool TrySetRetailState(uint stateId);
}
public static class RetailUiStateIds
{
public const uint Closed = 11u;
public const uint Open = 12u;
public const uint HideDetail = 0x10000006u;
public const uint ShowDetail = 0x10000007u;
public const uint ObjectSelected = 0x1000000Bu;
public const uint StackedItemSelected = 0x1000000Cu;
public const uint ItemSlotEmpty = 0x1000001Cu;
public const uint Maximized = 0x10000047u;
public const uint Minimized = 0x10000048u;
public const uint LockedUi = 0x10000063u;
public const uint UnlockedUi = 0x10000064u;
public static string StateName(uint stateId)
=> stateId switch
{
Closed => "Closed",
Open => "Open",
HideDetail => "HideDetail",
ShowDetail => "ShowDetail",
ObjectSelected => "ObjectSelected",
StackedItemSelected => "StackedItemSelected",
ItemSlotEmpty => "ItemSlot_Empty",
Maximized => "Maximized",
Minimized => "Minimized",
LockedUi => "LockedUI",
UnlockedUi => "UnlockedUI",
_ => "",
};
public static bool TryStateId(string stateName, out uint stateId)
{
stateId = stateName switch
{
"Closed" => Closed,
"Open" => Open,
"HideDetail" => HideDetail,
"ShowDetail" => ShowDetail,
"ObjectSelected" => ObjectSelected,
"StackedItemSelected" => StackedItemSelected,
"ItemSlot_Empty" => ItemSlotEmpty,
"Maximized" => Maximized,
"Minimized" => Minimized,
"LockedUI" => LockedUi,
"UnlockedUI" => UnlockedUi,
_ => 0u,
};
return stateId != 0;
}
}

View file

@ -0,0 +1,10 @@
namespace AcDream.App.UI;
/// <summary>
/// Opt-in recipient of retail UI global message 3, broadcast once per UI frame
/// after tooltip deadline processing.
/// </summary>
public interface IUiGlobalTimeListener
{
void OnGlobalUiTime(double nowSeconds);
}

View file

@ -138,8 +138,6 @@ public static class CharacterStatController
// State names as returned by UIStateId.ToString() — used as ActiveState keys on UiButton.
private const string StateOpen = "Open"; // UIStateId.Open = 0x0C — active tab
private const string StateClosed = "Closed"; // UIStateId.Closed = 0x0B — inactive tab
private const string StateNormal = "Normal"; // UIStateId.Normal = 0x01 — affordable / default
private const string StateGhosted = "Ghosted"; // UIStateId.Ghosted = 0x0D — disabled button
private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text
private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold
@ -338,6 +336,7 @@ public static class CharacterStatController
if (datFont is not null) xpValue.DatFont = datFont;
xpValue.ClickThrough = true;
xpValue.RightAligned = true;
xpValue.OneLine = true;
xpValue.Padding = 0f; // avoid scroll clip
xpValue.LinesProvider = () => new[] { new UiText.Line(data().XpToNextLevel.ToString("N0"), Body) };
}
@ -1053,12 +1052,20 @@ public static class CharacterStatController
bool affordable1 = cost1 > 0 && sheet.UnassignedXp >= cost1;
bool affordable10 = cost10 > 0 && sheet.UnassignedXp >= cost10;
// State "Normal" = affordable/green; "Ghosted" = too expensive or maxed.
string btn1State = affordable1 ? StateNormal : StateGhosted;
string btn10State = affordable10 ? StateNormal : StateGhosted;
foreach (var b in allRaise1) { b.Visible = true; b.ActiveState = btn1State; }
foreach (var b in allRaise10) { b.Visible = true; b.ActiveState = btn10State; }
foreach (var b in allRaise1)
{
b.Visible = true;
b.TrySetRetailState(affordable1
? UiButtonStateMachine.Normal
: UiButtonStateMachine.Ghosted);
}
foreach (var b in allRaise10)
{
b.Visible = true;
b.TrySetRetailState(affordable10
? UiButtonStateMachine.Normal
: UiButtonStateMachine.Ghosted);
}
}
private static void RefreshSkillRaiseButtons(
@ -1079,12 +1086,12 @@ public static class CharacterStatController
bool affordable = trained
? cost > 0 && sheet.UnassignedXp >= cost
: cost > 0 && sheet.SkillCredits >= cost;
string state = affordable ? StateNormal : StateGhosted;
foreach (var b in allRaise1)
{
b.Visible = true;
b.ActiveState = state;
b.TrySetRetailState(affordable
? UiButtonStateMachine.Normal
: UiButtonStateMachine.Ghosted);
}
foreach (var b in allRaise10)
@ -1094,7 +1101,9 @@ public static class CharacterStatController
{
long cost10 = selectedSkill.Raise10Cost;
bool affordable10 = cost10 > 0 && sheet.UnassignedXp >= cost10;
b.ActiveState = affordable10 ? StateNormal : StateGhosted;
b.TrySetRetailState(affordable10
? UiButtonStateMachine.Normal
: UiButtonStateMachine.Ghosted);
}
}
}
@ -1334,6 +1343,7 @@ public static class CharacterStatController
DatFont = datFont,
ClickThrough = true,
RightAligned = true,
OneLine = true,
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
var capturedProvider = valueProvider;
@ -1435,6 +1445,7 @@ public static class CharacterStatController
// BackgroundSprite cleared: its full-height sprite would cover line-1/line-2.
titleEl.BackgroundSprite = 0;
titleEl.VerticalJustify = VJustify.Top; // dat says Center; override to Top (see comment above)
titleEl.OneLine = true;
}
// Title (FooterTitle 0x1000024E, 20px from dat): pass null → keep dat font.
// Fix C: the dat has a 20px font for the footer title. Let it drive.
@ -1706,6 +1717,7 @@ public static class CharacterStatController
// Non-null = controller explicit override.
if (datFont is not null) t.DatFont = datFont;
t.Centered = true;
t.OneLine = true;
t.ClickThrough = true;
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
}
@ -1829,6 +1841,7 @@ public static class CharacterStatController
DatFont = datFont,
ClickThrough = true,
Centered = true,
OneLine = true,
Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
labelEl.LinesProvider = () => new[] { new UiText.Line(capturedLabel, labelColor) };

View file

@ -16,9 +16,9 @@ namespace AcDream.App.UI.Layout;
/// <para>
/// The transcript (<c>0x10000011</c>) is Type-12 and is built as a <see cref="UiText"/>
/// by the factory; this controller binds its live data provider in place. The input
/// (<c>0x10000016</c>) is also Type-12, so the factory builds it as an invisible
/// <see cref="UiText"/> placeholder; this controller removes that placeholder and adds
/// a <see cref="UiField"/> at the same rect. The scrollbar track (<c>0x10000012</c>) is
/// (<c>0x10000016</c>) carries Editable property 0x16, so the factory builds it
/// directly as <see cref="UiField"/> and this controller binds it in place. The
/// scrollbar track (<c>0x10000012</c>) is
/// built directly as a <see cref="UiScrollbar"/> by the factory (Type 11) and bound in
/// place. The channel menu (<c>0x10000014</c>) is built as <see cref="UiMenu"/> (Type 6)
/// and bound in place.
@ -36,7 +36,7 @@ public sealed class ChatWindowController
private const uint TrackId = 0x10000012u;
private const uint InputBarId = 0x10000013u;
private const uint MenuId = 0x10000014u;
private const uint InputId = 0x10000016u; // Type-12 Text — factory builds UiText placeholder; Bind removes + replaces with UiField
private const uint InputId = 0x10000016u; // Type-12 Text + Editable 0x16 → UiField
private const uint SendId = 0x10000019u;
private const uint MaxMinId = 0x1000046Fu;
@ -48,9 +48,6 @@ public sealed class ChatWindowController
private const uint UpSprite = 0x06004C6Cu; // up arrow (top button)
private const uint DownSprite = 0x06004C69u; // down arrow (bottom button)
// Chat input focused-field background (element 0x10000016 Normal_focussed state).
private const uint InputFocusField = 0x060011ABu; // gold "lit" field when in write mode
// Channel menu sprite ids (confirmed in chat element dump).
private const uint MenuNormal = 0x06004D65u; // button face
private const uint MenuPressed = 0x06004D66u; // button pressed
@ -159,20 +156,16 @@ public sealed class ChatWindowController
BitmapFont? debugFont,
Func<uint, (uint tex, int w, int h)> resolve)
{
// The transcript is built as a UiText by the factory (Type 12).
// The input node (0x10000016) is also Type-12 → UiText, but the controller replaces
// it with a UiField. Read its rect from the raw ElementInfo tree first.
var iInfo = FindInfo(rootInfo, InputId);
// Their parent panels must exist as real widgets in the layout tree.
var transcriptPanel = layout.FindElement(TranscriptPanelId);
var inputBar = layout.FindElement(InputBarId);
var input = layout.FindElement(InputId) as UiField;
if (iInfo is null || transcriptPanel is null || inputBar is null)
if (input is null || transcriptPanel is null || inputBar is null)
{
Console.WriteLine(
$"[D.2b] ChatWindowController.Bind: missing required elements " +
$"(iInfo={iInfo is not null}, " +
$"(input={input is not null}, " +
$"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " +
$"chat window will not be interactive.");
return null;
@ -215,29 +208,19 @@ public sealed class ChatWindowController
// caches layout for mouse selection and Ctrl+C.
c.Transcript.Centered = false;
c.Transcript.RightAligned = false;
c.Transcript.OneLine = false;
c.Transcript.Selectable = true;
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);
// ── Input ────────────────────────────────────────────────────────
// The input element (0x10000016) resolves to Type-12 Text, so the factory built it
// as an unbound (invisible) UiText placeholder in the input bar. The editable entry
// is a controller-placed UiField at the same rect — drop the placeholder, add the field.
if (layout.FindElement(InputId) is { Parent: { } inParent } inputPlaceholder)
inParent.RemoveChild(inputPlaceholder);
c.Input = new UiField
{
Left = iInfo.X,
Top = iInfo.Y,
Width = iInfo.Width,
Height = iInfo.Height,
Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom),
DatFont = datFont,
Font = debugFont,
BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f), // retail translucent unfocused field
SpriteResolve = resolve,
FocusFieldSprite = InputFocusField,
};
inputBar.AddChild(c.Input);
// Editable/selectable/one-line semantics and state sprites came from the
// imported property/state bags. The controller supplies runtime services only.
c.Input = input;
c.Input.DatFont = datFont;
c.Input.Font = debugFont;
c.Input.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f);
c.Input.SpriteResolve = resolve;
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
// ── Scrollbar — bind the factory-built Type-11 track element ────────
@ -360,21 +343,6 @@ public sealed class ChatWindowController
// ── Helpers ────────────────────────────────────────────────────────────
/// <summary>
/// Depth-first search for an <see cref="ElementInfo"/> node by id in the
/// raw info tree (which contains ALL elements, including the Type-12 skipped ones).
/// </summary>
private static ElementInfo? FindInfo(ElementInfo node, uint id)
{
if (node.Id == id) return node;
foreach (var child in node.Children)
{
var found = FindInfo(child, id);
if (found is not null) return found;
}
return null;
}
/// <summary>
/// Convert the ChatVM's detailed lines to the transcript's
/// <see cref="UiText.Line"/> record format, applying retail-faithful

View file

@ -10,11 +10,10 @@ namespace AcDream.App.UI.Layout;
/// <see cref="UiDatElement"/>.
///
/// <para>
/// Type 12 = UIElement_Text — a scrollable colored-line text view. Every Type-12
/// element is now built as a <see cref="UiText"/>. Elements that carry their own
/// dat sprite media keep it as the <see cref="UiText.BackgroundSprite"/>. Pure
/// prototype elements (no state media, no controller binding) draw nothing because
/// <see cref="UiText.BackgroundColor"/> defaults to transparent.
/// Type 12 = UIElement_Text. Editable `0x16` elements become <see cref="UiField"/>
/// in place; other elements become display/selectable <see cref="UiText"/> widgets.
/// Elements that carry their own DAT sprite media keep it as widget background art.
/// Pure prototype elements draw nothing because text backgrounds default transparent.
/// </para>
///
/// <para>
@ -102,9 +101,31 @@ public static class DatWidgetFactory
e.ZOrder = (int)info.ReadOrder - (int)info.ZLevel * 10000;
// Map the four raw edge-anchor values to the AnchorEdges bit-flag that the
// UI layout engine uses for reflow.
// compatibility layout engine uses for programmatic overrides.
e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom);
// Imported descendants use the exact four-mode retail policy. Roots have no
// design parent and intentionally remain on the compatibility path until a
// window mount assigns its own outer-frame policy.
if (info.HasOriginalParentSize)
{
e.LayoutPolicy = new UiLayoutPolicy(
info.Left,
info.Top,
info.Right,
info.Bottom,
UiPixelRect.FromPositionAndSize(
(int)info.X,
(int)info.Y,
(int)info.Width,
(int)info.Height),
UiPixelRect.FromPositionAndSize(
0,
0,
(int)info.OriginalParentWidth,
(int)info.OriginalParentHeight));
}
return e;
}
@ -253,7 +274,8 @@ public static class DatWidgetFactory
// ── Text ─────────────────────────────────────────────────────────────────
/// <summary>Type-12 UIElement_Text: a scrollable colored-line text view. The element's
/// <summary>Type-12 UIElement_Text: an editable field or colored-line text view,
/// selected from the canonical property bag. The element's
/// own Direct/Normal media (if any) becomes the background sprite, drawn under the text —
/// so a Type-12 element that previously rendered via UiDatElement keeps its sprite. Lines
/// are bound later by the controller (LinesProvider). An unbound UiText draws nothing
@ -273,7 +295,7 @@ public static class DatWidgetFactory
/// font; otherwise it is the shared global fallback. Controllers that call
/// <see cref="ImportedLayout.FindElement"/> and set <see cref="UiText.DatFont"/> afterward
/// still override this — the build-time value is just the starting point.</param>
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve,
private static UiElement BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve,
UiDatFont? elementFont = null)
{
uint bg = info.StateMedia.TryGetValue(
@ -281,6 +303,35 @@ public static class DatWidgetFactory
: info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m)
? m.File : 0u;
bool editable = info.TryGetEffectiveBool(0x16u, out var editableValue)
&& editableValue;
bool selectable = info.TryGetEffectiveBool(0x27u, out var selectableValue)
&& selectableValue;
bool oneLine = info.TryGetEffectiveBool(0x20u, out var oneLineValue)
&& oneLineValue;
if (editable)
{
uint focusSprite = info.StateMedia.TryGetValue("Normal_focussed", out var focus)
? focus.File
: 0u;
var field = new UiField
{
ElementId = info.Id,
DatFont = elementFont,
SpriteResolve = resolve,
BackgroundSprite = bg,
FocusFieldSprite = focusSprite,
Selectable = selectable,
OneLine = oneLine,
};
if (info.TryGetEffectiveInteger(0x1Eu, out int maxCharacters))
field.MaxCharacters = maxCharacters;
if (info.FontColor.HasValue)
field.TextColor = info.FontColor.Value;
return field;
}
// Apply horizontal + vertical justification from the dat at build time.
// Controllers that call FindElement and set Centered/RightAligned/VerticalJustify
// afterward will override these — this is only the dat-driven default.
@ -301,6 +352,8 @@ public static class DatWidgetFactory
Centered = centered,
RightAligned = rightAligned,
VerticalJustify = vJustify,
OneLine = oneLine,
Selectable = selectable,
// Seed the dat-driven font. When a font resolver was supplied and the element
// carries a non-zero FontDid, elementFont is the element-specific dat font; otherwise
// it is the shared global fallback. Controllers that call FindElement and explicitly

View file

@ -21,8 +21,8 @@ public enum VJustify : byte { Top = 0, Center = 1, Bottom = 2 }
/// 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.
/// This is the canonical importer/factory seam. Add fields only with matching
/// inheritance, fixture-serialization, and conformance coverage.
/// </summary>
public sealed class ElementInfo
{
@ -41,11 +41,20 @@ public sealed class ElementInfo
/// <summary>Position and size within the parent, in pixels (cast from dat uint fields).</summary>
public float X, Y, Width, Height;
/// <summary>
/// Design-time parent size used by retail raw-edge reflow. Set by the DAT
/// importer for descendants; imported roots deliberately leave this unset.
/// Mounted base-layout children retain their original base parent size.
/// </summary>
public float OriginalParentWidth, OriginalParentHeight;
public bool HasOriginalParentSize;
/// <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"/>.
/// Values 04. Imported elements preserve these in <see cref="UiLayoutPolicy"/>;
/// <see cref="ElementReader.ToAnchors"/> is only the compatibility projection
/// for older programmatic consumers.
/// </summary>
public uint Left, Top, Right, Bottom;
@ -58,6 +67,16 @@ public sealed class ElementInfo
/// within-layer tiebreaker. Issue #145 (vitals are all ZLevel 0, so they're unaffected).</summary>
public uint ZLevel;
/// <summary>
/// Canonical state descriptors keyed by numeric retail state id. The unnamed
/// DirectState uses <see cref="UiStateInfo.DirectStateId"/>. Unlike the legacy
/// projections below, properties remain scoped to the state that defines them.
/// </summary>
public Dictionary<uint, UiStateInfo> States = new();
/// <summary>Numeric form of <see cref="DefaultStateName"/>; zero means unset.</summary>
public uint DefaultStateId;
/// <summary>
/// Font dat object id inherited from the base element's <c>Properties[0x1A]</c>
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>). 0 = none / not inherited.
@ -115,6 +134,82 @@ public sealed class ElementInfo
/// Children come from the derived element's own tree, not the base element's.
/// </summary>
public List<ElementInfo> Children = new();
/// <summary>
/// Resolves a property for a state using retail's DirectState-as-base rule. A
/// named state's key overrides DirectState by presence, including false/zero.
/// When no state is supplied, the element's default, Normal, then DirectState
/// is selected in that order.
/// </summary>
public bool TryGetEffectiveProperty(uint propertyId, out UiPropertyValue value, uint? stateId = null)
{
uint effectiveState = stateId ?? EffectiveDefaultStateId();
UiPropertyValue? resolved = null;
if (States.TryGetValue(UiStateInfo.DirectStateId, out var direct)
&& direct.Properties.TryGetValue(propertyId, out var directValue))
{
resolved = directValue;
}
if (effectiveState != UiStateInfo.DirectStateId
&& States.TryGetValue(effectiveState, out var state)
&& state.Properties.TryGetValue(propertyId, out var stateValue))
{
resolved = stateValue;
}
value = resolved!;
return resolved is not null;
}
public bool TryGetEffectiveBool(uint propertyId, out bool value, uint? stateId = null)
{
if (TryGetEffectiveProperty(propertyId, out var property, stateId)
&& property.Kind == UiPropertyKind.Bool)
{
value = property.BoolValue;
return true;
}
value = false;
return false;
}
public bool TryGetEffectiveInteger(uint propertyId, out int value, uint? stateId = null)
{
if (TryGetEffectiveProperty(propertyId, out var property, stateId)
&& property.Kind == UiPropertyKind.Integer)
{
value = property.IntegerValue;
return true;
}
value = 0;
return false;
}
public bool TryGetEffectiveFloat(uint propertyId, out float value, uint? stateId = null)
{
if (TryGetEffectiveProperty(propertyId, out var property, stateId)
&& property.Kind == UiPropertyKind.Float)
{
value = property.FloatValue;
return true;
}
value = 0f;
return false;
}
public uint EffectiveDefaultStateId()
{
if (DefaultStateId != 0 && States.ContainsKey(DefaultStateId))
return DefaultStateId;
if (States.ContainsKey(1u)) // UIStateId.Normal
return 1u;
return UiStateInfo.DirectStateId;
}
}
/// <summary>
@ -125,12 +220,10 @@ public sealed class ElementInfo
/// </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>
/// <summary>Compatibility projection from raw retail modes to the legacy
/// <see cref="AnchorEdges"/> flags. This projection cannot represent centered
/// mode 3 or proportional mode 4 exactly. Imported DAT widgets therefore use
/// <see cref="UiLayoutPolicy"/>; call this only for legacy/programmatic paths.</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>
@ -180,12 +273,9 @@ public static class ElementReader
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 wins if non-zero. Layout instances commonly carry Type 0
// and inherit the registered widget type (for example Type 12 text) from
// their base prototype; DatWidgetFactory then builds that behavioral type.
Type = derived.Type != 0 ? derived.Type : base_.Type,
X = derived.X,
Y = derived.Y,
@ -193,7 +283,7 @@ public static class ElementReader
// 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).
// switch Width/Height to nullable values and use presence-aware merging.
Width = derived.Width != 0 ? derived.Width : base_.Width,
Height = derived.Height != 0 ? derived.Height : base_.Height,
Left = derived.Left,
@ -202,6 +292,7 @@ public static class ElementReader
Bottom = derived.Bottom,
ReadOrder = derived.ReadOrder,
ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel,
DefaultStateId = derived.DefaultStateId != 0 ? derived.DefaultStateId : base_.DefaultStateId,
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.
@ -216,7 +307,7 @@ public static class ElementReader
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
// from corrupting the other. Safe because derived.Children is fully
// populated by the recursive importer BEFORE Merge is called and never mutated after).
Children = new List<ElementInfo>(derived.Children),
};
@ -227,6 +318,68 @@ public static class ElementReader
m.StateCursors = new Dictionary<string, UiCursorMedia>(base_.StateCursors);
foreach (var kv in derived.StateCursors)
m.StateCursors[kv.Key] = kv.Value;
m.States = new Dictionary<uint, UiStateInfo>();
foreach (var (id, state) in base_.States)
m.States[id] = state.Clone();
foreach (var (id, state) in derived.States)
{
m.States[id] = m.States.TryGetValue(id, out var baseState)
? UiStateInfo.Merge(baseState, state)
: state.Clone();
}
ApplyCanonicalLegacyProjection(m);
return m;
}
internal static void ApplyCanonicalLegacyProjection(ElementInfo info)
{
if (info.TryGetEffectiveProperty(0x1Au, out var font)
&& font.Kind == UiPropertyKind.Array
&& font.ArrayValue.Count > 0
&& font.ArrayValue[0].Kind == UiPropertyKind.DataId)
{
info.FontDid = checked((uint)font.ArrayValue[0].UnsignedValue);
}
if (info.TryGetEffectiveProperty(0x14u, out var horizontal)
&& horizontal.Kind == UiPropertyKind.Enum)
{
info.HJustify = horizontal.UnsignedValue switch
{
0u => HJustify.Left,
3u or 5u => HJustify.Right,
_ => HJustify.Center,
};
}
if (info.TryGetEffectiveProperty(0x15u, out var vertical)
&& vertical.Kind == UiPropertyKind.Enum)
{
info.VJustify = vertical.UnsignedValue switch
{
2u => VJustify.Top,
4u => VJustify.Bottom,
_ => VJustify.Center,
};
}
if (info.TryGetEffectiveProperty(0x1Bu, out var color))
{
UiPropertyValue? colorValue = color.Kind == UiPropertyKind.Color
? color
: color.Kind == UiPropertyKind.Array
&& color.ArrayValue.Count > 0
&& color.ArrayValue[0].Kind == UiPropertyKind.Color
? color.ArrayValue[0]
: null;
if (colorValue is not null)
{
var c = colorValue.ColorValue;
float alpha = c.Alpha == 0 ? 1f : c.Alpha / 255f;
info.FontColor = new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha);
}
}
}
}

View file

@ -463,6 +463,7 @@ public sealed class InventoryController : IItemListDragHandler
if (host is UiText t)
{
t.Centered = true;
t.OneLine = true;
t.DatFont = datFont;
t.ClickThrough = true;
t.AcceptsFocus = false;
@ -483,7 +484,7 @@ public sealed class InventoryController : IItemListDragHandler
{
Left = 0f, Top = 0f, Width = host.Width, Height = host.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
Centered = true, DatFont = datFont, ClickThrough = true,
Centered = true, OneLine = true, DatFont = datFont, ClickThrough = true,
AcceptsFocus = false, IsEditControl = false, CapturesPointerDrag = false,
LinesProvider = () =>
{

View file

@ -203,9 +203,19 @@ public static class LayoutImporter
tops.Add(Resolve(dats, d, new HashSet<(uint, uint)>()));
}
return tops.Count == 1
? tops[0]
: new ElementInfo { Id = 0, Type = 3, Children = tops };
if (tops.Count == 1)
return tops[0];
foreach (var top in tops)
SetOriginalParentSize(top, ld.Width, ld.Height);
return new ElementInfo
{
Id = 0,
Type = 3,
Width = ld.Width,
Height = ld.Height,
Children = tops,
};
}
/// <summary>
@ -290,7 +300,11 @@ public static class LayoutImporter
// Resolve + attach children. Each child gets a FRESH base-chain set:
// the cycle guard is per-element, not shared across siblings.
foreach (var kv in d.Children)
result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>()));
{
var child = Resolve(dats, kv.Value, new HashSet<(uint, uint)>());
SetOriginalParentSize(child, result.Width, result.Height);
result.Children.Add(child);
}
// Sub-window mount: a pure-container leaf (no own children, no own media) that inherits
// from a base WITH content attaches the base's subtree. Targets the gmInventoryUI panels
@ -313,42 +327,9 @@ public static class LayoutImporter
// FRONT of the backdrop. (B-Controller debug 2026-06-21; continuation of #145.)
result.ZLevel = self.ZLevel;
// Sub-layout slot sizing: when a sub-layout is mounted into a slot that is TALLER
// (or wider) than the sub-layout's own design height, the cascade of Bottom/Right
// anchored elements must be resized to fill the slot. Without this step, the
// background element (0x10000226 for the Attributes tab, designed at 337px) captures
// _amB = slotH - designH = 238 and permanently stays at 337px, and every element
// within it with Bottom anchor stays at its design size too (list box at 160px
// instead of the correct 398px).
//
// Retail reference: UIElement::UpdateForParentSizeChange (C++ runtime) propagates a
// new parent height down the whole tree, updating each Bottom-anchored child's
// rect by maintaining its bottom margin. We replicate that cascade here at import
// time on the base-children subtree so the anchor-capture during the first render
// frame sees zero (or unchanged) margins — not the spurious "slot bigger than design"
// margin.
//
// Safe guard: only fire when the slot has explicit size AND the base children are
// smaller (i.e., this is the "slot bigger than design" case).
// Inventory/paperdoll slots are unaffected because their slot H already matches the
// sub-layout design H, so no cascade occurs.
if (result.Width > 0 && result.Height > 0)
{
foreach (var child in baseChildren)
{
var childAnchors = ElementReader.ToAnchors(child.Left, child.Top, child.Right, child.Bottom);
const AnchorEdges StretchAll = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
if (child.X == 0 && child.Y == 0
&& (childAnchors & StretchAll) == StretchAll // full-stretch background
&& child.Width <= result.Width
&& child.Height > 0 && child.Height < result.Height) // smaller than slot
{
// Cascade UpdateForParentSizeChange down the subtree: maintain each
// Bottom-anchored element's bottom margin while growing the parent height.
CascadeHeight(child, child.Height, result.Height);
}
}
}
// Mounted descendants retain the base layout's design parent size. The runtime
// UiLayoutPolicy therefore performs the retail parent-size update from the correct
// baseline when this slot is larger than the mounted layout.
}
return result;
@ -378,17 +359,19 @@ public static class LayoutImporter
Bottom = d.BottomEdge,
ReadOrder = d.ReadOrder,
ZLevel = d.ZLevel,
DefaultStateId = (uint)d.DefaultState,
DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState,
};
// DirectState (unnamed, key "").
if (d.StateDesc is not null)
ReadState(d.StateDesc, "", info);
ReadState(d.StateDesc, UiStateInfo.DirectStateId, "", info);
// Named states (e.g. UIStateId.HideDetail → "HideDetail").
foreach (var s in d.States)
ReadState(s.Value, s.Key.ToString(), info);
ReadState(s.Value, (uint)s.Key, s.Key.ToString(), info);
ElementReader.ApplyCanonicalLegacyProjection(info);
return info;
}
@ -398,14 +381,23 @@ public static class LayoutImporter
/// <c>info.StateCursors[name]</c>, and extract the font DID from property 0x1A
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>) if not yet set.
/// </summary>
private static void ReadState(StateDesc sd, string name, ElementInfo info)
private static void ReadState(StateDesc sd, uint stateId, string name, ElementInfo info)
{
var state = new UiStateInfo
{
Id = stateId,
Name = name,
PassToChildren = sd.PassToChildren,
IncorporationFlags = (uint)sd.IncorporationFlags,
};
bool imageRead = false;
foreach (var m in sd.Media)
{
if (!imageRead && m is MediaDescImage img && img.File != 0)
{
info.StateMedia[name] = (img.File, (int)img.DrawMode);
state.Image = new UiImageMedia(img.File, (int)img.DrawMode);
imageRead = true;
}
@ -415,9 +407,17 @@ public static class LayoutImporter
cursor.File,
checked((int)cursor.XHotspot),
checked((int)cursor.YHotspot));
state.Cursor = info.StateCursors[name];
}
}
if (sd.Properties is not null)
{
foreach (var (propertyId, property) in sd.Properties)
state.Properties.Values[propertyId] = ConvertProperty(property);
}
info.States[stateId] = state;
// Font DID: Properties[0x1A] is ArrayBaseProperty{ DataIdBaseProperty }.
// Format doc §3: "ArrayBaseProperty containing ONE DataIdBaseProperty".
if (info.FontDid == 0 && sd.Properties is not null
@ -476,6 +476,82 @@ public static class LayoutImporter
}
}
internal static UiPropertyValue ConvertProperty(BaseProperty property)
{
var value = new UiPropertyValue { MasterPropertyId = property.MasterPropertyId };
switch (property)
{
case EnumBaseProperty p:
value.Kind = UiPropertyKind.Enum;
value.UnsignedValue = p.Value;
break;
case BoolBaseProperty p:
value.Kind = UiPropertyKind.Bool;
value.BoolValue = p.Value;
break;
case DataIdBaseProperty p:
value.Kind = UiPropertyKind.DataId;
value.UnsignedValue = p.Value;
break;
case FloatBaseProperty p:
value.Kind = UiPropertyKind.Float;
value.FloatValue = p.Value;
break;
case IntegerBaseProperty p:
value.Kind = UiPropertyKind.Integer;
value.IntegerValue = p.Value;
break;
case StringInfoBaseProperty p:
value.Kind = UiPropertyKind.StringInfo;
value.StringInfoValue = new UiStringInfoValue(
p.Value.Token,
p.Value.StringId,
p.Value.TableId.DataId,
(byte)p.Value.Override,
p.Value.English,
p.Value.Comment);
break;
case ColorBaseProperty p:
value.Kind = UiPropertyKind.Color;
value.ColorValue = new UiColorValue(
p.Value.Blue,
p.Value.Green,
p.Value.Red,
p.Value.Alpha);
break;
case ArrayBaseProperty p:
value.Kind = UiPropertyKind.Array;
foreach (var item in p.Value)
value.ArrayValue.Add(ConvertProperty(item));
break;
case StructBaseProperty p:
value.Kind = UiPropertyKind.Struct;
foreach (var (key, item) in p.Value)
value.StructValue[key] = ConvertProperty(item);
break;
case VectorBaseProperty p:
value.Kind = UiPropertyKind.Vector;
value.VectorValue = p.Value;
break;
case Bitfield32BaseProperty p:
value.Kind = UiPropertyKind.Bitfield32;
value.UnsignedValue = p.Value;
break;
case Bitfield64BaseProperty p:
value.Kind = UiPropertyKind.Bitfield64;
value.UnsignedValue = p.Value;
break;
case InstanceIdBaseProperty p:
value.Kind = UiPropertyKind.InstanceId;
value.UnsignedValue = p.Value;
break;
default:
throw new NotSupportedException($"Unsupported UI base-property type {property.GetType().FullName}.");
}
return value;
}
// ── Prototype detection helpers ───────────────────────────────────────────
/// <summary>
@ -534,59 +610,12 @@ public static class LayoutImporter
return null;
}
// ── Sub-layout slot sizing helpers ────────────────────────────────────────
// ── Raw-edge layout provenance ────────────────────────────────────────────
/// <summary>
/// Recursively propagates a parent-height change from <paramref name="oldParentH"/>
/// to <paramref name="newParentH"/> through <paramref name="el"/> and all its
/// descendants, replicating <c>UIElement::UpdateForParentSizeChange</c> from the
/// retail runtime.
///
/// <para>Three anchor cases for the vertical axis:
/// <list type="bullet">
/// <item>Top+Bottom (stretch): maintain top margin AND bottom margin; height grows.</item>
/// <item>Bottom only (pin-to-bottom, fixed height): maintain bottom margin; Y moves, H unchanged.</item>
/// <item>Top only or None: Y and H unchanged (pinned to top with fixed height).</item>
/// </list>
/// Recurse with the element's new height so grandchildren see the correct parent size.</para>
///
/// <para>This is called once at import time on base-children that are mounted into a
/// slot taller than the sub-layout's design height.</para>
/// </summary>
private static void CascadeHeight(ElementInfo el, float oldParentH, float newParentH)
private static void SetOriginalParentSize(ElementInfo child, float width, float height)
{
float oldH = el.Height;
float newH = el.Height;
float oldY = el.Y;
var anchors = ElementReader.ToAnchors(el.Left, el.Top, el.Right, el.Bottom);
bool hasTop = (anchors & AnchorEdges.Top) != 0;
bool hasBottom = (anchors & AnchorEdges.Bottom) != 0;
if (hasTop && hasBottom)
{
// Stretch: maintain both top and bottom margins.
// amT = el.Y (pinned to top, unchanged)
// amB = oldParentH - (el.Y + el.H)
float amB = oldParentH - (el.Y + el.Height);
newH = newParentH - el.Y - amB;
if (newH < 0f) newH = 0f;
el.Height = newH;
}
else if (hasBottom && !hasTop)
{
// Pin to bottom: maintain bottom margin, height fixed, Y moves.
// amB = oldParentH - (el.Y + el.H)
float amB = oldParentH - (el.Y + el.Height);
float newY = newParentH - amB - el.Height;
el.Y = newY;
// Height unchanged; newH = oldH for recursion.
}
// else: Top-only or None — element is top-pinned with fixed height; nothing moves.
// Recurse into children with the element's new height as their parent.
foreach (var child in el.Children)
CascadeHeight(child, oldH, newH);
child.OriginalParentWidth = width;
child.OriginalParentHeight = height;
child.HasOriginalParentSize = true;
}
}

View file

@ -157,7 +157,8 @@ public sealed class RadarController
if (_dragButton is not null)
_dragButton.Visible = !snapshot.UiLocked;
if (_lockButton is not null)
_lockButton.ActiveState = snapshot.UiLocked ? "LockedUI" : "UnlockedUI";
_lockButton.TrySetRetailState(
snapshot.UiLocked ? RetailUiStateIds.LockedUi : RetailUiStateIds.UnlockedUi);
}
}
@ -195,6 +196,7 @@ public sealed class RadarController
// behavioral widget so its 0x06004CC0 background stays the actual coordinate
// strip and no duplicate text node is needed.
importedText.Centered = true;
importedText.OneLine = true;
importedText.Padding = 0f;
importedText.DatFont = datFont ?? importedText.DatFont;
importedText.ClickThrough = true;
@ -214,6 +216,7 @@ public sealed class RadarController
Height = container.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
Centered = true,
OneLine = true,
Padding = 0f,
DatFont = datFont,
ClickThrough = true,

View file

@ -146,6 +146,7 @@ public sealed class SelectedObjectController
Left = 0f, Top = 0f, Width = _name.Width, Height = NameBandHeight,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
Centered = true,
OneLine = true,
DatFont = datFont,
ClickThrough = true,
AcceptsFocus = false,
@ -215,7 +216,7 @@ public sealed class SelectedObjectController
if (guid is null)
{
// Deselect: clear the overlay flash immediately too.
SetOverlayState("");
SetOverlayState(UiStateInfo.DirectStateId);
_flashRemaining = 0;
return;
}
@ -227,7 +228,9 @@ public sealed class SelectedObjectController
// ── 3. Selection overlay: brief flash (retail container ObjectSelected
// = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ──────────────
SetOverlayState(_stackSize(g) > 1u ? "StackedItemSelected" : "ObjectSelected");
SetOverlayState(_stackSize(g) > 1u
? RetailUiStateIds.StackedItemSelected
: RetailUiStateIds.ObjectSelected);
_flashRemaining = FlashSeconds;
// ── 4. Health: query, and show the meter only if real health is already known.
@ -258,11 +261,11 @@ public sealed class SelectedObjectController
if (_flashRemaining <= 0) return;
_flashRemaining -= deltaSeconds;
if (_flashRemaining <= 0)
SetOverlayState(""); // flash done → overlay back to blank
SetOverlayState(UiStateInfo.DirectStateId); // flash done → overlay back to blank
}
private void SetOverlayState(string state)
private void SetOverlayState(uint state)
{
if (_overlay is not null) _overlay.ActiveState = state;
_overlay?.TrySetRetailState(state);
}
}

View file

@ -56,8 +56,6 @@ public sealed class ToolbarController : IItemListDragHandler
private const uint CharacterButtonId = 0x10000199u;
private const uint InventoryButtonId = 0x100001B1u;
private const string ButtonClosedState = "Normal";
private const string ButtonOpenState = "Highlight";
private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length];
private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length];
@ -247,7 +245,9 @@ public sealed class ToolbarController : IItemListDragHandler
private static void SetButtonOpen(UiButton? button, bool open)
{
if (button is not null)
button.ActiveState = open ? ButtonOpenState : ButtonClosedState;
button.TrySetRetailState(open
? UiButtonStateMachine.Highlight
: UiButtonStateMachine.Normal);
}
/// <summary>

View file

@ -28,7 +28,7 @@ namespace AcDream.App.UI.Layout;
/// <c>GL_REPEAT</c> on both S and T, so vertical tiling is always active.
/// </para>
/// </summary>
public sealed class UiDatElement : UiElement
public sealed class UiDatElement : UiElement, IUiDatStateful
{
// DrawModeType enum values from DatReaderWriter.Enums.
// See docs/research/2026-06-15-layoutdesc-format.md §6.
@ -51,8 +51,50 @@ public sealed class UiDatElement : UiElement
/// 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)
{
if (stateId == UiStateInfo.DirectStateId)
{
if (!_info.States.ContainsKey(stateId) && !_info.StateMedia.ContainsKey(""))
return false;
ActiveState = "";
return true;
}
if (_info.States.TryGetValue(stateId, out var state))
{
ActiveState = state.Name;
return true;
}
string stateName = UiButtonStateMachine.StateName(stateId);
if (string.IsNullOrEmpty(stateName))
stateName = RetailUiStateIds.StateName(stateId);
if (!string.IsNullOrEmpty(stateName) && _info.StateMedia.ContainsKey(stateName))
{
ActiveState = stateName;
return true;
}
return false;
}
/// <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>

View file

@ -0,0 +1,152 @@
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Dat property value kinds carried by a retail <c>StateDesc</c>. The enum mirrors
/// <c>BasePropertyType</c> without exposing DatReaderWriter types to the retained UI.
/// </summary>
public enum UiPropertyKind : byte
{
Enum,
Bool,
DataId,
Float,
Integer,
StringInfo,
Color,
Array,
Struct,
Vector,
Bitfield32,
Bitfield64,
InstanceId,
}
/// <summary>Exact byte representation of a retail ARGB property value.</summary>
public readonly record struct UiColorValue(byte Blue, byte Green, byte Red, byte Alpha);
/// <summary>Dat-independent snapshot of a retail <c>StringInfo</c>.</summary>
public readonly record struct UiStringInfoValue(
byte Token,
uint StringId,
uint TableId,
byte Override,
byte English,
byte Comment);
/// <summary>
/// Lossless, serializable representation of one supported retail base property.
/// Only the field selected by <see cref="Kind"/> is meaningful. Arrays and structs
/// recursively preserve their members and each member's master-property id.
/// </summary>
public sealed class UiPropertyValue
{
public UiPropertyKind Kind;
public uint MasterPropertyId;
public ulong UnsignedValue;
public int IntegerValue;
public float FloatValue;
public bool BoolValue;
public UiStringInfoValue StringInfoValue;
public UiColorValue ColorValue;
public Vector3 VectorValue;
public List<UiPropertyValue> ArrayValue = new();
public Dictionary<uint, UiPropertyValue> StructValue = new();
public UiPropertyValue Clone()
{
var clone = new UiPropertyValue
{
Kind = Kind,
MasterPropertyId = MasterPropertyId,
UnsignedValue = UnsignedValue,
IntegerValue = IntegerValue,
FloatValue = FloatValue,
BoolValue = BoolValue,
StringInfoValue = StringInfoValue,
ColorValue = ColorValue,
VectorValue = VectorValue,
};
foreach (var item in ArrayValue)
clone.ArrayValue.Add(item.Clone());
foreach (var (key, value) in StructValue)
clone.StructValue[key] = value.Clone();
return clone;
}
}
/// <summary>
/// State-scoped retail properties. Presence of a key is authoritative: an explicit
/// false, zero, centered justification, or empty collection still overrides a base
/// value during inheritance.
/// </summary>
public sealed class UiPropertyBag
{
public Dictionary<uint, UiPropertyValue> Values = new();
public bool TryGetValue(uint id, out UiPropertyValue value)
=> Values.TryGetValue(id, out value!);
public UiPropertyBag Clone()
{
var clone = new UiPropertyBag();
foreach (var (key, value) in Values)
clone.Values[key] = value.Clone();
return clone;
}
public static UiPropertyBag Merge(UiPropertyBag baseProperties, UiPropertyBag derivedProperties)
{
var merged = baseProperties.Clone();
foreach (var (key, value) in derivedProperties.Values)
merged.Values[key] = value.Clone();
return merged;
}
}
/// <summary>Primary render-surface media for a retail UI state.</summary>
public readonly record struct UiImageMedia(uint File, int DrawMode);
/// <summary>
/// Dat-independent state descriptor. DirectState uses
/// <see cref="DirectStateId"/> so it cannot collide with <c>UIStateId.Undef == 0</c>.
/// </summary>
public sealed class UiStateInfo
{
public const uint DirectStateId = uint.MaxValue;
public uint Id;
public string Name = "";
public bool PassToChildren;
public uint IncorporationFlags;
public UiImageMedia? Image;
public UiCursorMedia? Cursor;
public UiPropertyBag Properties = new();
public UiStateInfo Clone()
=> new()
{
Id = Id,
Name = Name,
PassToChildren = PassToChildren,
IncorporationFlags = IncorporationFlags,
Image = Image,
Cursor = Cursor,
Properties = Properties.Clone(),
};
public static UiStateInfo Merge(UiStateInfo baseState, UiStateInfo derivedState)
=> new()
{
Id = derivedState.Id,
Name = derivedState.Name,
PassToChildren = derivedState.PassToChildren,
IncorporationFlags = derivedState.IncorporationFlags,
Image = derivedState.Image ?? baseState.Image,
Cursor = derivedState.Cursor ?? baseState.Cursor,
Properties = UiPropertyBag.Merge(baseState.Properties, derivedState.Properties),
};
}

View file

@ -27,6 +27,9 @@ public static class VitalsController
public const uint Stamina = 0x100000EC;
/// <summary>Dat element id for the Mana meter (0x100000EE).</summary>
public const uint Mana = 0x100000EE;
public const uint HealthText = 0x100000EB;
public const uint StaminaText = 0x100000ED;
public const uint ManaText = 0x100000EF;
/// <summary>
/// Bind live vitals data providers to the Health, Stamina, and Mana meter
@ -50,16 +53,16 @@ public static class VitalsController
Func<string> staminaText,
Func<string> manaText)
{
BindMeter(layout, Health, healthPct, healthText);
BindMeter(layout, Stamina, staminaPct, staminaText);
BindMeter(layout, Mana, manaPct, manaText);
BindMeter(layout, Health, HealthText, healthPct, healthText);
BindMeter(layout, Stamina, StaminaText, staminaPct, staminaText);
BindMeter(layout, Mana, ManaText, manaPct, manaText);
}
/// <summary>White cur/max numbers — matches the former <c>UiMeter.LabelColor</c> default.</summary>
private static readonly Vector4 NumberColor = new(1f, 1f, 1f, 1f);
private static void BindMeter(
ImportedLayout layout, uint id,
ImportedLayout layout, uint id, uint textId,
Func<float> pct,
Func<string> text)
{
@ -68,31 +71,22 @@ public static class VitalsController
m.Fill = () => pct();
// Retail gmVitalsUI renders the cur/max as a real UIElement_Text centered over the
// bar — NOT a meter-internal label. Attach a centered UiText (non-interactive
// decoration) that fills + stretches with the meter, and stop the meter drawing its
// own label. UiText.Centered uses the SAME centering formula the meter's overlay did,
// so the numbers stay pixel-identical (locked by the visual gate).
// Retail gmVitalsUI binds the authored UIElement_Text over each meter. The
// importer already built and registered that node; do not synthesize a duplicate.
m.Label = () => null;
if (layout.FindElement(textId) is not UiText number) return;
var number = new UiText
number.Centered = true;
number.RightAligned = false;
number.OneLine = true;
number.Selectable = false;
number.DatFont ??= m.DatFont;
number.LinesProvider = () =>
{
Left = 0f, Top = 0f, Width = m.Width, Height = m.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
Centered = true,
DatFont = m.DatFont, // the same dat font the meter used for its label
ClickThrough = true, // decoration: no focus / selection / drag
AcceptsFocus = false,
IsEditControl = false,
CapturesPointerDrag = false,
LinesProvider = () =>
{
var s = text();
return string.IsNullOrEmpty(s)
? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(s, NumberColor) };
},
var s = text();
return string.IsNullOrEmpty(s)
? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(s, NumberColor) };
};
m.AddChild(number);
}
}

View file

@ -30,10 +30,17 @@ namespace AcDream.App.UI;
/// earlier dev-scaffold widget with no dat sprites.
/// </para>
/// </summary>
public sealed class UiButton : UiElement
public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
{
private readonly ElementInfo _info;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
private readonly HashSet<uint> _availableStates = new();
private bool _pressed;
private bool _pointerOver;
private bool _selected;
private bool _hotClicking;
private bool _suppressNextClick;
private double _nextHotClickTime = double.NaN;
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
public Action? OnClick { get; set; }
@ -57,14 +64,84 @@ public sealed class UiButton : UiElement
/// <summary>Label horizontal alignment options.</summary>
public enum LabelAlignment { Center, Left }
public bool ToggleBehavior { get; }
public bool RolloverEnabled { get; }
public bool HotClickEnabled { get; }
public float HotClickInitialDelay { get; }
public float HotClickRepeatInterval { get; }
public bool Selected
{
get => _selected;
set
{
if (_selected == value) return;
_selected = value;
UpdateVisualState();
}
}
/// <summary>
/// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized).
/// Matches <see cref="UiDatElement.ActiveState"/>.
/// </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)
{
if (ToggleBehavior && stateId is UiButtonStateMachine.Normal or UiButtonStateMachine.Highlight)
{
Selected = stateId == UiButtonStateMachine.Highlight;
return true;
}
if (stateId == UiButtonStateMachine.Ghosted)
{
Enabled = false;
return true;
}
if (!Enabled && stateId != UiButtonStateMachine.Ghosted)
Enabled = true;
if (stateId == UiStateInfo.DirectStateId)
{
if (!_info.States.ContainsKey(stateId) && !_info.StateMedia.ContainsKey(""))
return false;
ActiveState = "";
return true;
}
if (_info.States.TryGetValue(stateId, out var state))
{
ActiveState = state.Name;
return true;
}
string stateName = UiButtonStateMachine.StateName(stateId);
if (string.IsNullOrEmpty(stateName))
stateName = RetailUiStateIds.StateName(stateId);
if (!string.IsNullOrEmpty(stateName) && _info.StateMedia.ContainsKey(stateName))
{
ActiveState = stateName;
return true;
}
return false;
}
/// <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>
@ -74,6 +151,25 @@ public sealed class UiButton : UiElement
_resolve = resolve;
ClickThrough = false; // buttons are interactive — opt OUT of click-through
foreach (uint stateId in info.States.Keys)
if (stateId != UiStateInfo.DirectStateId)
_availableStates.Add(stateId);
foreach (string stateName in info.StateMedia.Keys)
if (UiButtonStateMachine.TryStateId(stateName, out uint stateId))
_availableStates.Add(stateId);
ToggleBehavior = info.TryGetEffectiveBool(0x0Bu, out bool toggle) && toggle;
RolloverEnabled = info.TryGetEffectiveBool(0x13u, out bool rollover) && rollover;
HotClickEnabled = info.TryGetEffectiveBool(0x0Fu, out bool hotClick) && hotClick;
HotClickInitialDelay = info.TryGetEffectiveFloat(0x10u, out float initialDelay)
? initialDelay
: 0f;
HotClickRepeatInterval = info.TryGetEffectiveFloat(0x11u, out float repeatInterval)
? repeatInterval
: 0f;
_selected = info.TryGetEffectiveBool(0x0Eu, out bool selected) && selected;
bool disabled = info.TryGetEffectiveBool(0x0Du, out bool ghosted) && ghosted;
// State defaulting matches UiDatElement exactly:
// DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState ("").
if (!string.IsNullOrEmpty(info.DefaultStateName))
@ -81,6 +177,9 @@ public sealed class UiButton : UiElement
else if (info.StateMedia.ContainsKey("Normal"))
ActiveState = "Normal";
// else ActiveState stays "" (DirectState)
Enabled = !disabled;
UpdateVisualState();
}
/// <summary>The button draws its own face + label; any dat label child is reproduced
@ -128,7 +227,103 @@ public sealed class UiButton : UiElement
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
return false;
switch (e.Type)
{
case UiEventType.HoverEnter:
_pointerOver = true;
UpdateVisualState();
return true;
case UiEventType.HoverLeave:
_pointerOver = false;
UpdateVisualState();
return true;
case UiEventType.MouseDown:
_pointerOver = ContainsLocal(e.Data1, e.Data2);
_pressed = true;
UpdateVisualState();
if (HotClickEnabled && Enabled)
{
OnClick?.Invoke();
_hotClicking = true;
_nextHotClickTime = double.NaN;
}
return true;
case UiEventType.MouseMove:
if (_pressed)
{
_pointerOver = ContainsLocal(e.Data1, e.Data2);
UpdateVisualState();
return true;
}
return false;
case UiEventType.MouseUp:
_pointerOver = ContainsLocal(e.Data1, e.Data2);
_suppressNextClick = _hotClicking && _pointerOver;
_hotClicking = false;
_nextHotClickTime = double.NaN;
if (_pressed && _pointerOver && Enabled && ToggleBehavior)
_selected = !_selected;
_pressed = false;
UpdateVisualState();
return true;
case UiEventType.Click:
if (!Enabled) return true;
if (_suppressNextClick)
{
_suppressNextClick = false;
return true;
}
OnClick?.Invoke();
return OnClick is not null;
default:
return false;
}
}
protected override void OnEnabledChanged()
{
if (!Enabled)
{
_pressed = false;
_hotClicking = false;
_nextHotClickTime = double.NaN;
}
UpdateVisualState();
}
public void OnGlobalUiTime(double nowSeconds)
{
if (!_hotClicking || !HotClickEnabled)
return;
if (double.IsNaN(_nextHotClickTime))
_nextHotClickTime = nowSeconds + HotClickInitialDelay;
if (!_pointerOver && nowSeconds >= _nextHotClickTime)
{
_nextHotClickTime = nowSeconds;
return;
}
if (_pointerOver && nowSeconds >= _nextHotClickTime)
{
OnClick?.Invoke();
_nextHotClickTime += HotClickRepeatInterval;
}
}
private bool ContainsLocal(int x, int y)
=> x >= 0 && y >= 0 && x < Width && y < Height;
private void UpdateVisualState()
{
uint requested = UiButtonStateMachine.RequestedState(new UiButtonVisualInput(
Disabled: !Enabled,
Selected: _selected,
RolloverEnabled: RolloverEnabled,
Pressed: _pressed,
PointerOver: _pointerOver));
if (_availableStates.Contains(requested))
ActiveState = UiButtonStateMachine.StateName(requested);
}
}

View file

@ -0,0 +1,79 @@
namespace AcDream.App.UI;
/// <summary>Inputs to retail <c>UIElement_Button::UpdateState_</c>.</summary>
public readonly record struct UiButtonVisualInput(
bool Disabled,
bool Selected,
bool RolloverEnabled,
bool Pressed,
bool PointerOver);
/// <summary>
/// GL-free retail button visual-state policy. State ids are the numeric
/// <c>UIStateId</c> values from the DAT.
/// </summary>
public static class UiButtonStateMachine
{
public const uint Normal = 1u;
public const uint NormalRollover = 2u;
public const uint NormalPressed = 3u;
public const uint Highlight = 6u;
public const uint HighlightRollover = 7u;
public const uint HighlightPressed = 8u;
public const uint Ghosted = 13u;
public static uint RequestedState(UiButtonVisualInput input)
{
if (input.Disabled)
return Ghosted;
uint requested = input.Selected ? Highlight : Normal;
if (input.Pressed || (input.RolloverEnabled && input.PointerOver))
requested = input.Selected ? HighlightRollover : NormalRollover;
if (input.Pressed && input.PointerOver)
requested = input.Selected ? HighlightPressed : NormalPressed;
return requested;
}
/// <summary>
/// Returns the requested state when it exists; otherwise returns the current
/// state unchanged. Retail does not search for a nearest fallback.
/// </summary>
public static uint ResolveState(
uint currentState,
UiButtonVisualInput input,
IReadOnlySet<uint> availableStates)
{
uint requested = RequestedState(input);
return availableStates.Contains(requested) ? requested : currentState;
}
public static string StateName(uint stateId)
=> stateId switch
{
Normal => "Normal",
NormalRollover => "Normal_rollover",
NormalPressed => "Normal_pressed",
Highlight => "Highlight",
HighlightRollover => "Highlight_rollover",
HighlightPressed => "Highlight_pressed",
Ghosted => "Ghosted",
_ => "",
};
public static bool TryStateId(string stateName, out uint stateId)
{
stateId = stateName switch
{
"Normal" => Normal,
"Normal_rollover" => NormalRollover,
"Normal_pressed" => NormalPressed,
"Highlight" => Highlight,
"Highlight_rollover" => HighlightRollover,
"Highlight_pressed" => HighlightPressed,
"Ghosted" => Ghosted,
_ => 0u,
};
return stateId != 0;
}
}

View file

@ -121,7 +121,17 @@ public abstract class UiElement
// ── State flags ─────────────────────────────────────────────────────
public bool Visible { get; set; } = true;
public bool Enabled { get; set; } = true;
private bool _enabled = true;
public bool Enabled
{
get => _enabled;
set
{
if (_enabled == value) return;
_enabled = value;
OnEnabledChanged();
}
}
/// <summary>
/// If true, <see cref="HitTest"/> skips this element — the event
@ -206,7 +216,28 @@ public abstract class UiElement
/// <summary>Edges this element anchors to in its parent. Default Left|Top
/// (pinned top-left, fixed size — no reflow). Left|Right stretches width.</summary>
public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top;
private AnchorEdges _anchors = AnchorEdges.Left | AnchorEdges.Top;
/// <summary>Edges this programmatic element anchors to in its parent. Assigning
/// this compatibility policy explicitly opts out of an imported DAT
/// <see cref="LayoutPolicy"/>.</summary>
public AnchorEdges Anchors
{
get => _anchors;
set
{
_anchors = value;
LayoutPolicy = null;
_anchorCaptured = false;
}
}
/// <summary>
/// Exact raw-edge policy for imported DAT elements. Null for roots and
/// programmatic widgets. Controllers may set null to opt out or call
/// <see cref="UiLayoutPolicy.Rebase"/> after intentional geometry changes.
/// </summary>
public UiLayoutPolicy? LayoutPolicy { get; set; }
// ── Tree structure ──────────────────────────────────────────────────
public UiElement? Parent { get; private set; }
@ -223,7 +254,9 @@ public abstract class UiElement
public virtual bool RemoveChild(UiElement child)
{
if (!_children.Remove(child)) return false;
if (!_children.Contains(child)) return false;
FindRoot()?.OnSubtreeRemoving(child);
_children.Remove(child);
child.Parent = null;
return true;
}
@ -271,6 +304,9 @@ public abstract class UiElement
/// <summary>Per-frame tick (animations, timers, caret blink).</summary>
protected virtual void OnTick(double deltaSeconds) { }
/// <summary>Called synchronously after <see cref="Enabled"/> changes.</summary>
protected virtual void OnEnabledChanged() { }
/// <summary>
/// Custom hit-test override. Default is a rectangle containment
/// check on (<see cref="Width"/>, <see cref="Height"/>).
@ -296,8 +332,8 @@ public abstract class UiElement
public virtual (uint tex, int w, int h)? GetDragGhost() => null;
/// <summary>
/// Tooltip text for this widget. Retail fires event 0x07 after
/// ~1000ms hover, then queries the widget's virtual "GetString"
/// Tooltip text for this widget. Retail fires event 0x07 after the configured
/// hover delay (0.25 seconds by default), then queries the widget's virtual "GetString"
/// (vtable +0x88) to render the tooltip body.
/// </summary>
public virtual string? GetTooltipText() => null;
@ -418,6 +454,22 @@ public abstract class UiElement
/// Called by the parent each frame before drawing children.</summary>
internal void ApplyAnchor(float parentW, float parentH)
{
if (LayoutPolicy is not null)
{
var current = UiPixelRect.FromPositionAndSize(
(int)Left,
(int)Top,
(int)Width,
(int)Height);
var parent = UiPixelRect.FromPositionAndSize(0, 0, (int)parentW, (int)parentH);
var next = LayoutPolicy.Apply(current, parent);
Left = next.X0;
Top = next.Y0;
Width = next.Width;
Height = next.Height;
return;
}
if (Anchors == AnchorEdges.None) return;
if (!_anchorCaptured)
{

View file

@ -39,8 +39,8 @@ public readonly record struct UiEvent(
/// Evidence from decompile:
/// - 0x01 click — chunk_00470000.c ~11140, chunk_004C0000.c ~9270
/// - 0x05/0x06 hover — chunk_00460000.c ~6253
/// - 0x07 tooltip — chunk_00460000.c ~6253 (delayed via
/// Device::RegisterTimerEvent(7, widget, delayMs))
/// - 0x07 tooltip — chunk_00460000.c ~6253 (the UI manager polls the
/// hovered element's tooltip deadline before global time)
/// - 0x0A scroll — chunk_00470000.c ~11210
/// - 0x0E right-click— chunk_004A0000.c ~2674
/// - 0x15 drag begin — chunk_004A0000.c ~2707

View file

@ -22,6 +22,7 @@ namespace AcDream.App.UI;
/// </summary>
public sealed class UiField : UiElement
{
public uint ElementId { get; set; }
public UiDatFont? DatFont { get; set; }
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
@ -30,6 +31,8 @@ public sealed class UiField : UiElement
public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f);
public float Padding { get; set; } = 4f;
public int MaxCharacters { get; set; } = 0xFFFF;
public bool OneLine { get; set; } = true;
public bool Selectable { get; set; }
/// <summary>Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift).
/// Wired by the host from <see cref="UiHost.Keyboard"/>.</summary>
@ -38,6 +41,8 @@ public sealed class UiField : UiElement
/// <summary>Dat sprite resolver (id → GL texture + size) for the focused-field
/// background. Null = fall back to the flat <see cref="BackgroundColor"/> rect.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>Unfocused/default state sprite imported from the DAT.</summary>
public uint BackgroundSprite { get; set; }
/// <summary>Gold "lit" field background drawn when focused (retail Normal_focussed
/// state, RenderSurface 0x060011AB). 0 = no focus sprite.</summary>
public uint FocusFieldSprite { get; set; }
@ -262,7 +267,8 @@ public sealed class UiField : UiElement
protected override void OnDraw(UiRenderContext ctx)
{
// Focused = "write mode": draw the gold lit field sprite (retail Normal_focussed).
// Unfocused: the flat translucent rect. Both go through the sprite bucket
// Unfocused: draw the imported default state, then the flat translucent fallback.
// Both go through the sprite bucket
// (DrawFill / DrawSprite) so the text — also sprite-bucket — draws on top.
bool lit = _focused && SpriteResolve is not null && FocusFieldSprite != 0;
if (lit)
@ -271,6 +277,16 @@ public sealed class UiField : UiElement
if (tex != 0 && tw > 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
else lit = false;
}
if (!lit && SpriteResolve is not null && BackgroundSprite != 0)
{
var (tex, tw, th) = SpriteResolve(BackgroundSprite);
if (tex != 0 && tw > 0 && th > 0)
{
ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f,
Width / tw, Height / th, Vector4.One);
lit = true;
}
}
if (!lit) ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
float lh = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
@ -366,11 +382,11 @@ public sealed class UiField : UiElement
case UiEventType.MouseDown:
_caret = HitCharX(e.Data1);
_selAnchor = _caret; // anchor; a drag will extend, a plain click won't
_selecting = true;
_selAnchor = Selectable ? _caret : null;
_selecting = Selectable;
return true;
case UiEventType.MouseMove:
if (_selecting) _caret = HitCharX(e.Data1);
if (Selectable && _selecting) _caret = HitCharX(e.Data1);
return true;
case UiEventType.MouseUp:
_selecting = false;
@ -387,15 +403,15 @@ public sealed class UiField : UiElement
{
switch (key)
{
case Silk.NET.Input.Key.A: SelectAll(); return true;
case Silk.NET.Input.Key.C: CopySelection(); return true;
case Silk.NET.Input.Key.X: CutSelection(); return true;
case Silk.NET.Input.Key.A when Selectable: SelectAll(); return true;
case Silk.NET.Input.Key.C when Selectable: CopySelection(); return true;
case Silk.NET.Input.Key.X when Selectable: CutSelection(); return true;
case Silk.NET.Input.Key.V: Paste(); return true;
}
return true; // swallow other Ctrl combos while typing
}
bool shift = ShiftHeld();
bool shift = Selectable && ShiftHeld();
switch (key)
{
case Silk.NET.Input.Key.Enter:

View file

@ -0,0 +1,151 @@
namespace AcDream.App.UI;
/// <summary>An inclusive integer pixel box, matching retail <c>Box2D</c>.</summary>
public readonly record struct UiPixelRect(int X0, int Y0, int X1, int Y1)
{
public int Width => X1 - X0 + 1;
public int Height => Y1 - Y0 + 1;
public static UiPixelRect FromPositionAndSize(int x, int y, int width, int height)
=> new(x, y, x + width - 1, y + height - 1);
}
/// <summary>
/// Exact retail raw-edge layout policy from
/// <c>UIElement::UpdateForParentSizeChange @ 0x00462640</c>. Imported DAT elements
/// retain all four independent modes; programmatic widgets continue to use
/// <see cref="AnchorEdges"/>.
/// </summary>
public sealed class UiLayoutPolicy
{
public uint LeftMode { get; }
public uint TopMode { get; }
public uint RightMode { get; }
public uint BottomMode { get; }
public UiPixelRect OriginalChild { get; private set; }
public UiPixelRect OriginalParent { get; private set; }
public bool PreserveCurrentWhenEmpty { get; set; }
public UiLayoutPolicy(
uint leftMode,
uint topMode,
uint rightMode,
uint bottomMode,
UiPixelRect originalChild,
UiPixelRect originalParent)
{
ValidateMode(leftMode, nameof(leftMode));
ValidateMode(topMode, nameof(topMode));
ValidateMode(rightMode, nameof(rightMode));
ValidateMode(bottomMode, nameof(bottomMode));
LeftMode = leftMode;
TopMode = topMode;
RightMode = rightMode;
BottomMode = bottomMode;
OriginalChild = originalChild;
OriginalParent = originalParent;
}
/// <summary>
/// Makes a controller-supplied geometry change the new design baseline while
/// retaining the raw retail edge modes.
/// </summary>
public void Rebase(UiPixelRect child, UiPixelRect parent)
{
OriginalChild = child;
OriginalParent = parent;
}
public UiPixelRect Apply(UiPixelRect currentChild, UiPixelRect currentParent)
=> Apply(
LeftMode,
TopMode,
RightMode,
BottomMode,
OriginalChild,
OriginalParent,
currentChild,
currentParent,
PreserveCurrentWhenEmpty);
public static UiPixelRect Apply(
uint leftMode,
uint topMode,
uint rightMode,
uint bottomMode,
UiPixelRect originalChild,
UiPixelRect originalParent,
UiPixelRect currentChild,
UiPixelRect currentParent,
bool preserveCurrentWhenEmpty = false)
{
ValidateMode(leftMode, nameof(leftMode));
ValidateMode(topMode, nameof(topMode));
ValidateMode(rightMode, nameof(rightMode));
ValidateMode(bottomMode, nameof(bottomMode));
int deltaX = currentParent.Width - originalParent.Width;
int deltaY = currentParent.Height - originalParent.Height;
double scaleX = originalParent.Width != 0
? (double)currentParent.Width / originalParent.Width
: 0d;
double scaleY = originalParent.Height != 0
? (double)currentParent.Height / originalParent.Height
: 0d;
int x0 = ApplyNear(leftMode, originalChild.X0, originalChild.Width,
currentParent.Width, deltaX, scaleX);
int y0 = ApplyNear(topMode, originalChild.Y0, originalChild.Height,
currentParent.Height, deltaY, scaleY);
int x1 = ApplyFar(rightMode, originalChild.X1, originalChild.Width,
currentParent.Width, deltaX, scaleX);
int y1 = ApplyFar(bottomMode, originalChild.Y1, originalChild.Height,
currentParent.Height, deltaY, scaleY);
if (currentChild.Width != 0 || currentChild.Height != 0 || preserveCurrentWhenEmpty)
{
if (leftMode == 0) x0 = currentChild.X0;
if (topMode == 0) y0 = currentChild.Y0;
if (rightMode == 0) x1 = currentChild.X1;
if (bottomMode == 0) y1 = currentChild.Y1;
}
return new UiPixelRect(x0, y0, x1, y1);
}
private static int ApplyNear(
uint mode,
int originalEdge,
int originalSize,
int currentParentSize,
int parentDelta,
double scale)
=> mode switch
{
2 => originalEdge + parentDelta,
3 => currentParentSize / 2 - originalSize / 2,
4 => (int)(originalEdge * scale),
_ => originalEdge,
};
private static int ApplyFar(
uint mode,
int originalEdge,
int originalSize,
int currentParentSize,
int parentDelta,
double scale)
=> mode switch
{
1 => originalEdge + parentDelta,
3 => currentParentSize / 2 + originalSize / 2 - 1,
4 => (int)(originalEdge * scale),
_ => originalEdge,
};
private static void ValidateMode(uint mode, string parameterName)
{
if (mode > 4)
throw new ArgumentOutOfRangeException(parameterName, mode, "Retail edge mode must be in 0..4.");
}
}

View file

@ -9,24 +9,15 @@ namespace AcDream.App.UI;
public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 }
/// <summary>
/// Top-level UI container. Implements the retail "Device" responsibilities
/// Top-level UI container. Implements the retail <c>UIElementManager</c> responsibilities
/// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture,
/// drag-drop state machine, tooltip timer). Routes Silk.NET input events
/// into the widget tree with retail-faithful <see cref="UiEvent"/>
/// semantics.
///
/// Retail analog: the <c>DAT_00837ff4</c> Device object (see
/// <c>docs/research/retail-ui/04-input-events.md §2</c>). That object has
/// a ~20-slot vtable; the methods we emulate here are:
///
/// <list type="bullet">
/// <item>+0x18 / +0x1C : <see cref="MouseX"/> / <see cref="MouseY"/></item>
/// <item>+0x34 : <see cref="RegisterTimerEvent"/> (tooltip delay)</item>
/// <item>+0x38 : <see cref="FireEvent"/></item>
/// <item>+0x44 : <see cref="KeyboardFocus"/></item>
/// <item>+0x48 / +0x4C : <see cref="SetCapture"/> / <see cref="ReleaseCapture"/></item>
/// <item>+0x74 / +0x78 : drag cursor set / reset</item>
/// </list>
/// Retail analog: <c>UIElementManager::UseTime @ 0x0045CFD0</c>. Tooltip
/// deadlines are polled before global time message 3; there is no generic Device
/// timer queue in the named client.
///
/// When no widget consumes an event, the <see cref="WorldMouseFallThrough"/>
/// or <see cref="WorldKeyFallThrough"/> event fires so the game world
@ -125,8 +116,10 @@ public sealed class UiRoot : UiElement
// Hover / tooltip tracking.
private UiElement? _hoverWidget;
private long _hoverStartedMs;
private const int TooltipDelayMs = 1000; // retail typical
public int TooltipDelayMs { get; set; } = 250;
public int TooltipDurationMs { get; set; } = 10_000;
private bool _tooltipFired;
private long _tooltipShownMs;
private long _nowMs;
@ -152,10 +145,92 @@ public sealed class UiRoot : UiElement
public override void AddChild(UiElement child)
{
if (child.EventId == 0) child.EventId = _nextEventId++;
AssignEventIds(child);
base.AddChild(child);
}
private void AssignEventIds(UiElement element)
{
if (element.EventId == 0)
element.EventId = _nextEventId++;
foreach (var child in element.Children)
AssignEventIds(child);
}
private static void BroadcastGlobalUiTime(UiElement element, double nowSeconds)
{
if (element is IUiGlobalTimeListener listener)
listener.OnGlobalUiTime(nowSeconds);
// A listener may synchronously close/remove a window. Snapshot the walk,
// then skip children no longer owned by this parent so a deleted subtree
// cannot receive a stale pulse and collection mutation cannot invalidate it.
foreach (var child in element.Children.ToArray())
if (ReferenceEquals(child.Parent, element))
BroadcastGlobalUiTime(child, nowSeconds);
}
internal void OnSubtreeRemoving(UiElement subtree)
{
if (IsWithinSubtree(KeyboardFocus, subtree))
SetKeyboardFocus(null);
if (IsWithinSubtree(Captured, subtree))
{
ReleaseCapture();
_dragCandidate = false;
}
if (IsWithinSubtree(DefaultTextInput, subtree))
DefaultTextInput = null;
if (IsWithinSubtree(Modal, subtree))
Modal = null;
if (IsWithinSubtree(DragSource, subtree))
{
DragSource = null;
DragPayload = null;
_dragGhost = null;
_dragCandidate = false;
}
if (IsWithinSubtree(_hoverWidget, subtree))
{
var leave = new UiEvent(_hoverWidget!.EventId, _hoverWidget, UiEventType.HoverLeave);
_hoverWidget.OnEvent(in leave);
_hoverWidget = null;
_tooltipFired = false;
}
if (IsWithinSubtree(_lastDragHoverTarget, subtree))
_lastDragHoverTarget = null;
if (IsWithinSubtree(_lastClickTarget, subtree))
_lastClickTarget = null;
if (IsWithinSubtree(_windowDragTarget, subtree))
{
_windowDragTarget = null;
_dragCandidate = false;
}
if (IsWithinSubtree(_resizeTarget, subtree))
{
_resizeTarget = null;
_dragCandidate = false;
}
foreach (string name in _windows
.Where(pair => IsWithinSubtree(pair.Value, subtree))
.Select(pair => pair.Key)
.ToArray())
{
_windows.Remove(name);
}
}
private static bool IsWithinSubtree(UiElement? element, UiElement subtree)
{
while (element is not null)
{
if (ReferenceEquals(element, subtree)) return true;
element = element.Parent;
}
return false;
}
// ── Per-frame pumping ────────────────────────────────────────────────
public void Tick(double dt, long nowMs)
@ -170,8 +245,18 @@ public sealed class UiRoot : UiElement
var e = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.Tooltip);
_hoverWidget.OnEvent(in e);
_tooltipFired = true;
_tooltipShownMs = _nowMs;
}
else if (_hoverWidget is not null && _tooltipFired
&& _nowMs - _tooltipShownMs >= TooltipDurationMs)
{
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
_hoverWidget.OnEvent(in leave);
_hoverWidget = null;
_tooltipFired = false;
}
BroadcastGlobalUiTime(this, nowMs / 1000d);
TickSelfAndChildren(dt);
}
@ -550,7 +635,13 @@ public sealed class UiRoot : UiElement
}
public void SetCapture(UiElement e) => Captured = e;
public void ReleaseCapture() => Captured = null;
public void ReleaseCapture()
{
Captured = null;
// Retail restarts the tooltip idle deadline when capture is released.
_hoverStartedMs = _nowMs;
_tooltipFired = false;
}
// ── Window manager (named top-level windows: Show / Hide / Toggle) ───
@ -694,14 +785,6 @@ public sealed class UiRoot : UiElement
target.OnEvent(in e);
}
public void RegisterTimerEvent(int type, UiElement target, int delayMs,
object? payload = null)
{
_timers.Add((_nowMs + delayMs, new UiEvent(target.EventId, target, type, Payload: payload)));
}
private readonly List<(long fireAt, UiEvent e)> _timers = new();
private void UpdateButtonFlag(UiMouseButton b, bool down)
{
switch (b)

View file

@ -15,10 +15,9 @@ namespace AcDream.App.UI;
/// text inside the window.
///
/// <para>
/// Supports Windows-like text selection: a left-click-drag inside the transcript
/// selects characters (the <see cref="UiElement.CapturesPointerDrag"/> opt-out
/// stops that interior drag from moving the host window), and Ctrl+C copies the
/// selected span to the clipboard. Ctrl+A selects everything.
/// When retail Selectable property `0x27` is enabled, left-click-drag selects
/// characters, Ctrl+C copies the selected span, and Ctrl+A selects everything.
/// Display-only text remains click-through and cannot steal focus or window drag.
/// </para>
/// </summary>
public sealed class UiText : UiElement
@ -80,6 +79,37 @@ public sealed class UiText : UiElement
/// <summary>Inner text inset from the view edges, px.</summary>
public float Padding { get; set; } = 4f;
/// <summary>Retail property 0x20. Independent of horizontal/vertical
/// justification; false permits the normal multi-line layout path.</summary>
public bool OneLine { get; set; }
private bool _selectable;
/// <summary>
/// Retail property 0x27. A display-only text element does not claim the mouse,
/// focus, or pointer drag. Selection and clipboard behavior are enabled only
/// when this capability is true.
/// </summary>
public bool Selectable
{
get => _selectable;
set
{
if (_selectable == value) return;
_selectable = value;
ClickThrough = !value;
AcceptsFocus = value;
IsEditControl = value;
CapturesPointerDrag = value;
if (!value)
{
_selecting = false;
_selAnchor = null;
_selCaret = null;
}
}
}
/// <summary>Static centered single-line mode (retail <c>UIElement_Text</c> center
/// justification): draws the FIRST line centered horizontally AND vertically in the
/// element rect, with NO scroll/selection machinery. Used for static labels such as
@ -135,9 +165,11 @@ public sealed class UiText : UiElement
public UiText()
{
AcceptsFocus = true;
IsEditControl = true; // absorb keys (Ctrl+C) while focused
CapturesPointerDrag = true; // interior drag selects, doesn't move the window
// UIElement_Text starts display-only (m_bitField = DIRTY|CURSOR_VISIBLE).
ClickThrough = true;
AcceptsFocus = false;
IsEditControl = false;
CapturesPointerDrag = false;
}
/// <summary>The text view draws its own lines + background; any dat sub-elements
@ -174,7 +206,7 @@ public sealed class UiText : UiElement
// Static centered single-line mode (vitals cur/max numbers etc.): draw the first
// line centered H+V (or H+Top/Bottom per VerticalJustify) with the SAME formula
// UIElement_Meter used for its label, then skip the scroll/selection machinery entirely.
if (Centered)
if (OneLine && Centered)
{
var cLines = LinesProvider();
if (cLines.Count == 0) return;
@ -196,7 +228,7 @@ public sealed class UiText : UiElement
// Static right-aligned single-line mode: draw the first line flush with the right
// edge, vertical position per VerticalJustify, then skip the scroll/selection machinery.
if (RightAligned)
if (OneLine && RightAligned)
{
var rLines = LinesProvider();
if (rLines.Count == 0) return;
@ -216,6 +248,26 @@ public sealed class UiText : UiElement
return;
}
// One-line is an independent text-layout property. Left justification uses
// the same vertical policy without turning alignment into a line-count flag.
if (OneLine)
{
var singleLines = LinesProvider();
if (singleLines.Count == 0) return;
var line0 = singleLines[0];
if (DatFont is { } datSingle)
{
float y = VOffset(Height, datSingle.LineHeight, Padding, VerticalJustify);
ctx.DrawStringDat(datSingle, line0.Text, Padding, y, line0.Color);
}
else if ((Font ?? ctx.DefaultFont) is { } bitmapSingle)
{
float y = VOffset(Height, bitmapSingle.LineHeight, Padding, VerticalJustify);
ctx.DrawString(line0.Text, Padding, y, line0.Color, bitmapSingle);
}
return;
}
// Prefer the retail dat font when set; fall back to BitmapFont.
var datFont = DatFont;
var bitmapFont = datFont is null ? (Font ?? ctx.DefaultFont) : null;
@ -301,6 +353,7 @@ public sealed class UiText : UiElement
{
case UiEventType.Scroll:
{
if (!Selectable) return false;
// Silk wheel +Y = scroll up = reveal older = toward the TOP = decrease ScrollY.
// ScrollByLines sign: +down/newer, -up/older.
// e.Data0 > 0 → wheel up → want older → ScrollByLines with negative lines.
@ -311,6 +364,7 @@ public sealed class UiText : UiElement
case UiEventType.MouseDown:
{
if (!Selectable) return false;
// Data1/Data2 = local-to-target coords (UiRoot.OnMouseDown).
var p = HitChar(e.Data1, e.Data2);
_selAnchor = p;
@ -321,6 +375,7 @@ public sealed class UiText : UiElement
case UiEventType.MouseMove:
{
if (!Selectable) return false;
if (_selecting)
{
// Data1/Data2 = local-to-target coords (DispatchMouseMove).
@ -332,12 +387,14 @@ public sealed class UiText : UiElement
case UiEventType.MouseUp:
{
if (!Selectable) return false;
_selecting = false;
return true;
}
case UiEventType.KeyDown:
{
if (!Selectable) return false;
var key = (Silk.NET.Input.Key)e.Data0;
bool ctrl = Keyboard is not null
&& (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlLeft)

View file

@ -1528,10 +1528,12 @@ public class CharacterStatControllerTests
};
}
/// <summary>Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites).</summary>
/// <summary>Manufacture a minimal UiButton with retail normal/ghosted states.</summary>
private static UiButton MakeButton(uint id = 0u)
{
var info = new ElementInfo { Id = id, Type = 1 };
info.StateMedia["Normal"] = (1u, 1);
info.StateMedia["Ghosted"] = (2u, 1);
return new UiButton(info, static _ => (0u, 0, 0));
}

View file

@ -1,3 +1,4 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
@ -43,4 +44,18 @@ public class ChatLayoutConformanceTests
Assert.Equal(12u, Find(root, 0x10000011u)!.Type); // Text/style-prototype (transcript)
Assert.Equal(12u, Find(root, 0x10000016u)!.Type); // Text/style-prototype (input)
}
[Fact]
public void ChatFixture_BuildsSelectableTranscriptAndEditableInputInPlace()
{
var layout = FixtureLoader.LoadChat();
var transcript = Assert.IsType<UiText>(layout.FindElement(0x10000011u));
var input = Assert.IsType<UiField>(layout.FindElement(0x10000016u));
Assert.True(transcript.Selectable);
Assert.True(input.Selectable);
Assert.True(input.OneLine);
Assert.Equal(0x10000016u, input.DatElementId);
}
}

View file

@ -15,9 +15,8 @@ namespace AcDream.App.Tests.UI.Layout;
/// real chat layout hierarchy (root → transcript panel + input bar as Type-3
/// containers, with Type-12 children for transcript + input, plus a Type-3 track
/// and menu), call <see cref="LayoutImporter.Build"/> to get the widget tree
/// (Type-12 children skipped, Type-3 parents created), then call
/// <see cref="ChatWindowController.Bind"/> which reads rects from the info tree
/// and places behavioral widgets under the parent containers.
/// (Type-12 children become property-driven text/field widgets), then call
/// <see cref="ChatWindowController.Bind"/> which binds those widgets in place.
/// </summary>
public class ChatWindowControllerTests
{
@ -42,7 +41,7 @@ public class ChatWindowControllerTests
/// track (Type-3) [0x10000012] ← Type-3 in test (not Type-11); Bind skips scrollbar bind
/// inputBar (Type-3) [0x10000013]
/// menu (Type-6) [0x10000014]
/// input (Type-12, no media) [0x10000016] ← built as UiText by factory; Bind removes + replaces with UiField
/// input (Type-12, Editable+Selectable) [0x10000016] ← built as UiField
/// send (Type-3) [0x10000019]
/// maxmin (Type-3) [0x1000046F]
/// </summary>
@ -71,9 +70,26 @@ public class ChatWindowControllerTests
};
var inputNode = new ElementInfo
{
Id = 0x10000016u, Type = 12, // Type-12, no media → skipped by factory
Id = 0x10000016u, Type = 12,
X = 46, Y = 0, Width = 398, Height = 17,
};
var inputState = new UiStateInfo { Id = UiStateInfo.DirectStateId };
inputState.Properties.Values[0x16u] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = true,
};
inputState.Properties.Values[0x20u] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = true,
};
inputState.Properties.Values[0x27u] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = true,
};
inputNode.States[UiStateInfo.DirectStateId] = inputState;
var sendNode = new ElementInfo
{
Id = 0x10000019u, Type = 3, X = 444, Y = 0, Width = 46, Height = 17,

View file

@ -42,7 +42,42 @@ public class DatWidgetFactoryTests
public void Type12_Text_MakesUiText()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null);
Assert.IsType<UiText>(e);
var text = Assert.IsType<UiText>(e);
Assert.False(text.Selectable);
Assert.True(text.ClickThrough);
Assert.False(text.AcceptsFocus);
Assert.False(text.CapturesPointerDrag);
}
[Fact]
public void Type12_EditableProperty_MakesUiFieldInPlace()
{
var info = TextInfo(
(0x16u, Bool(true)),
(0x20u, Bool(true)),
(0x27u, Bool(true)),
(0x1Eu, Integer(80)));
var field = Assert.IsType<UiField>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(info.Id, field.ElementId);
Assert.True(field.OneLine);
Assert.True(field.Selectable);
Assert.Equal(80, field.MaxCharacters);
}
[Fact]
public void Type12_SelectableProperty_MakesSelectableUiText()
{
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(
TextInfo((0x27u, Bool(true))),
NoTex,
null));
Assert.True(text.Selectable);
Assert.False(text.ClickThrough);
Assert.True(text.AcceptsFocus);
Assert.True(text.CapturesPointerDrag);
}
// ── Test 4: Rect + anchors set from ElementInfo ───────────────────────────
@ -74,6 +109,46 @@ public class DatWidgetFactoryTests
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors);
}
[Fact]
public void ImportedDescendant_PreservesExactRawEdgePolicy()
{
var info = new ElementInfo
{
Type = 3,
X = 10,
Y = 20,
Width = 30,
Height = 40,
Left = 4,
Top = 3,
Right = 1,
Bottom = 0,
OriginalParentWidth = 100,
OriginalParentHeight = 200,
HasOriginalParentSize = true,
};
var element = DatWidgetFactory.Create(info, NoTex, null)!;
var policy = Assert.IsType<UiLayoutPolicy>(element.LayoutPolicy);
Assert.Equal(4u, policy.LeftMode);
Assert.Equal(3u, policy.TopMode);
Assert.Equal(1u, policy.RightMode);
Assert.Equal(0u, policy.BottomMode);
Assert.Equal(new UiPixelRect(0, 0, 99, 199), policy.OriginalParent);
}
[Fact]
public void ImportedRoot_HasNoRawEdgePolicy()
{
var element = DatWidgetFactory.Create(
new ElementInfo { Type = 3, Width = 100, Height = 200 },
NoTex,
null)!;
Assert.Null(element.LayoutPolicy);
}
[Fact]
public void Create_PropagatesStateCursors()
{
@ -339,6 +414,22 @@ public class DatWidgetFactoryTests
Assert.Equal(gold, t.DefaultColor);
}
private static ElementInfo TextInfo(params (uint Id, UiPropertyValue Value)[] properties)
{
var info = new ElementInfo { Id = 0x10000016u, Type = 12, Width = 100, Height = 20 };
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
foreach (var property in properties)
state.Properties.Values[property.Id] = property.Value;
info.States[UiStateInfo.DirectStateId] = state;
return info;
}
private static UiPropertyValue Bool(bool value)
=> new() { Kind = UiPropertyKind.Bool, BoolValue = value };
private static UiPropertyValue Integer(int value)
=> new() { Kind = UiPropertyKind.Integer, IntegerValue = value };
/// <summary>
/// When ElementInfo.FontColor is null (dat carried no 0x1B), DefaultColor must
/// stay at white (Vector4.One) — the backward-compatible default.

View file

@ -65,6 +65,30 @@ public static class FixtureLoader
public static AcDream.App.UI.Layout.ElementInfo LoadRadarInfos()
=> LoadInfos("radar_21000074.json");
public static ImportedLayout LoadToolbar()
=> LayoutImporter.Build(LoadToolbarInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadToolbarInfos()
=> LoadInfos("toolbar_21000016.json");
public static ImportedLayout LoadInventory()
=> LayoutImporter.Build(LoadInventoryInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadInventoryInfos()
=> LoadInfos("inventory_21000023.json");
public static ImportedLayout LoadPaperdoll()
=> LayoutImporter.Build(LoadPaperdollInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadPaperdollInfos()
=> LoadInfos("paperdoll_21000024.json");
public static ImportedLayout LoadCharacter()
=> LayoutImporter.Build(LoadCharacterInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadCharacterInfos()
=> LoadInfos("character_2100002E.json");
// ── Shared loader ────────────────────────────────────────────────────────
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)

View file

@ -139,6 +139,36 @@ public class LayoutConformanceTests
Assert.Contains(0x40000000u, fontDids);
}
[Fact]
public void VitalsBinding_ReusesAuthoredTextNodesWithoutDuplicates()
{
var layout = FixtureLoader.LoadVitals();
VitalsController.Bind(
layout,
() => 1f,
() => 1f,
() => 1f,
() => "100/100",
() => "90/100",
() => "80/100");
(uint MeterId, uint TextId)[] cases =
[
(VitalsController.Health, VitalsController.HealthText),
(VitalsController.Stamina, VitalsController.StaminaText),
(VitalsController.Mana, VitalsController.ManaText),
];
foreach (var (meterId, textId) in cases)
{
var meter = Assert.IsType<UiMeter>(layout.FindElement(meterId));
var text = Assert.IsType<UiText>(layout.FindElement(textId));
Assert.Same(meter, text.Parent);
Assert.Single(meter.Children, child => child == text);
Assert.False(text.Selectable);
}
}
private static void CollectFontDids(ElementInfo node, System.Collections.Generic.List<uint> acc)
{
if (node.FontDid != 0) acc.Add(node.FontDid);

View file

@ -0,0 +1,88 @@
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class RetailFixtureConformanceTests
{
[Theory]
[MemberData(nameof(LayoutCases))]
public void ProductionFixture_PreservesRootAndCanonicalStates(
Func<ElementInfo> load,
uint rootId,
uint rootType,
float width,
float height,
int minimumNodeCount)
{
var root = load();
Assert.Equal(rootId, root.Id);
Assert.Equal(rootType, root.Type);
Assert.Equal(width, root.Width);
Assert.Equal(height, root.Height);
Assert.True(CountNodes(root) >= minimumNodeCount);
Assert.Contains(UiStateInfo.DirectStateId, root.States.Keys);
Assert.Contains(Enumerate(root), element => element.States.Count > 1);
Assert.Contains(Enumerate(root), element =>
element.States.Values.Any(state => state.Properties.Values.Count > 0));
}
[Fact]
public void ToolbarFixture_ContainsObjectButtonsAndRetailButtonStates()
{
var root = FixtureLoader.LoadToolbarInfos();
var character = Find(root, 0x10000199u);
var inventory = Find(root, 0x100001B1u);
Assert.NotNull(character);
Assert.NotNull(inventory);
Assert.Contains(1u, character.States.Keys); // Normal
Assert.Contains(3u, character.States.Keys); // Normal_pressed
Assert.Contains(6u, character.States.Keys); // Highlight
}
[Fact]
public void InventoryFixture_ContainsMountedBackpackAndPaperdollContent()
{
var root = FixtureLoader.LoadInventoryInfos();
Assert.NotNull(Find(root, InventoryController.ContentsGridId));
Assert.NotNull(Find(root, InventoryController.ContainerListId));
Assert.NotNull(Find(root, InventoryController.BurdenMeterId));
Assert.NotNull(Find(root, 0x100001D5u)); // mounted paperdoll viewport
}
[Fact]
public void CharacterFixture_ContainsAllThreeRuntimePages()
{
var root = FixtureLoader.LoadCharacterInfos();
Assert.NotNull(Find(root, CharacterStatController.AttributesPageId));
Assert.NotNull(Find(root, CharacterStatController.SkillsPageId));
Assert.NotNull(Find(root, CharacterStatController.TitlesPageId));
}
public static TheoryData<Func<ElementInfo>, uint, uint, float, float, int> LayoutCases
=> new()
{
{ FixtureLoader.LoadToolbarInfos, 0x10000191u, 0x10000007u, 300f, 122f, 20 },
{ FixtureLoader.LoadInventoryInfos, 0x100001CCu, 0x10000023u, 300f, 362f, 40 },
{ FixtureLoader.LoadPaperdollInfos, 0x100001D4u, 0x10000024u, 224f, 214f, 20 },
{ FixtureLoader.LoadCharacterInfos, 0x10000227u, 8u, 300f, 600f, 60 },
};
private static int CountNodes(ElementInfo root)
=> Enumerate(root).Count();
private static IEnumerable<ElementInfo> Enumerate(ElementInfo root)
{
yield return root;
foreach (var child in root.Children)
foreach (var descendant in Enumerate(child))
yield return descendant;
}
private static ElementInfo? Find(ElementInfo root, uint id)
=> Enumerate(root).FirstOrDefault(element => element.Id == id);
}

View file

@ -0,0 +1,60 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Regenerates every committed retail layout fixture from production portal.dat.
/// The test is inert unless <c>ACDREAM_REGENERATE_UI_FIXTURES=1</c> is set, keeping
/// normal test runs deterministic and dat-independent.
/// </summary>
public sealed class RetailLayoutFixtureGenerator
{
private static readonly (uint Id, string FileName)[] Layouts =
{
(0x21000006u, "chat_21000006.json"),
(0x21000016u, "toolbar_21000016.json"),
(0x21000023u, "inventory_21000023.json"),
(0x21000024u, "paperdoll_21000024.json"),
(0x2100002Eu, "character_2100002E.json"),
(0x2100006Cu, "vitals_2100006C.json"),
(0x21000074u, "radar_21000074.json"),
};
[Fact]
public void RegenerateAllRetailFixtures_WhenExplicitlyRequested()
{
if (!string.Equals(
Environment.GetEnvironmentVariable("ACDREAM_REGENERATE_UI_FIXTURES"),
"1",
StringComparison.Ordinal))
{
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);
foreach (var (layoutId, fileName) in Layouts)
{
var info = LayoutImporter.ImportInfos(dats, layoutId);
Assert.NotNull(info);
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
{
IncludeFields = true,
WriteIndented = true,
});
File.WriteAllText(Path.Combine(FixtureDirectory(), fileName), json);
}
}
private static string FixtureDirectory([CallerFilePath] string thisFile = "")
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures");
}

View file

@ -1,3 +1,4 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
@ -87,4 +88,42 @@ public class UiDatElementTests
// No DefaultStateName, no "Normal" state → ActiveState stays "" (DirectState).
Assert.Equal(0x06007777u, e.ActiveMedia().File);
}
[Fact]
public void NumericStateBridge_SetsNamedStateWithoutChangingGeometry()
{
var info = new ElementInfo { X = 4, Y = 5, Width = 30, Height = 40 };
info.States[RetailUiStateIds.ShowDetail] = new UiStateInfo
{
Id = RetailUiStateIds.ShowDetail,
Name = "ShowDetail",
Image = new UiImageMedia(0x06000009u, 3),
};
info.StateMedia["ShowDetail"] = (0x06000009u, 3);
var element = new UiDatElement(info, _ => (0u, 0, 0))
{
Left = info.X,
Top = info.Y,
Width = info.Width,
Height = info.Height,
};
Assert.True(element.TrySetRetailState(RetailUiStateIds.ShowDetail));
Assert.Equal(RetailUiStateIds.ShowDetail, element.ActiveRetailStateId);
Assert.Equal((0x06000009u, 3), element.ActiveMedia());
Assert.Equal((4f, 5f, 30f, 40f),
(element.Left, element.Top, element.Width, element.Height));
}
[Fact]
public void NumericStateBridge_MissingStatePreservesCurrentState()
{
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);
}
}

View file

@ -0,0 +1,138 @@
using System.Numerics;
using AcDream.App.UI.Layout;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.UI.Layout;
public sealed class UiPropertyBagTests
{
[Fact]
public void Merge_UsesKeyPresence_ForExplicitFalseAndZero()
{
var baseBag = new UiPropertyBag();
baseBag.Values[0x16u] = Bool(true);
baseBag.Values[0x14u] = Enum(3u);
var derivedBag = new UiPropertyBag();
derivedBag.Values[0x16u] = Bool(false);
derivedBag.Values[0x14u] = Enum(0u);
var merged = UiPropertyBag.Merge(baseBag, derivedBag);
Assert.False(merged.Values[0x16u].BoolValue);
Assert.Equal(0ul, merged.Values[0x14u].UnsignedValue);
}
[Fact]
public void EffectiveProperty_NamedStateOverridesDirectStateByPresence()
{
var info = new ElementInfo { DefaultStateId = 1u };
info.States[UiStateInfo.DirectStateId] = State(
UiStateInfo.DirectStateId,
"",
(0x16u, Bool(true)));
info.States[1u] = State(1u, "Normal", (0x16u, Bool(false)));
Assert.True(info.TryGetEffectiveProperty(0x16u, out var property));
Assert.False(property.BoolValue);
}
[Fact]
public void ElementMerge_ExplicitCenteredStatePropertyOverridesBaseLeft()
{
var baseInfo = new ElementInfo();
baseInfo.States[UiStateInfo.DirectStateId] = State(
UiStateInfo.DirectStateId,
"",
(0x14u, Enum(0u)));
var derivedInfo = new ElementInfo();
derivedInfo.States[UiStateInfo.DirectStateId] = State(
UiStateInfo.DirectStateId,
"",
(0x14u, Enum(1u)));
var merged = ElementReader.Merge(baseInfo, derivedInfo);
Assert.Equal(HJustify.Center, merged.HJustify);
Assert.Equal(1ul, merged.States[UiStateInfo.DirectStateId]
.Properties.Values[0x14u].UnsignedValue);
}
[Fact]
public void ConvertProperty_PreservesNestedRetailValues()
{
var source = new StructBaseProperty { MasterPropertyId = 0xA0u };
var array = new ArrayBaseProperty { MasterPropertyId = 0xA1u };
array.Value.Add(new IntegerBaseProperty { MasterPropertyId = 0xA2u, Value = -17 });
array.Value.Add(new Bitfield64BaseProperty
{
MasterPropertyId = 0xA3u,
Value = 0xFEDCBA9876543210ul,
});
source.Value[0x55u] = array;
source.Value[0x56u] = new VectorBaseProperty
{
MasterPropertyId = 0xA4u,
Value = new Vector3(1.25f, -2.5f, 9.75f),
};
var converted = LayoutImporter.ConvertProperty(source);
Assert.Equal(UiPropertyKind.Struct, converted.Kind);
Assert.Equal(0xA0u, converted.MasterPropertyId);
var convertedArray = converted.StructValue[0x55u];
Assert.Equal(UiPropertyKind.Array, convertedArray.Kind);
Assert.Equal(0xA1u, convertedArray.MasterPropertyId);
Assert.Equal(-17, convertedArray.ArrayValue[0].IntegerValue);
Assert.Equal(0xFEDCBA9876543210ul, convertedArray.ArrayValue[1].UnsignedValue);
Assert.Equal(new Vector3(1.25f, -2.5f, 9.75f), converted.StructValue[0x56u].VectorValue);
}
[Fact]
public void ConvertProperty_PreservesStringInfoAndColorBytes()
{
var text = new StringInfoBaseProperty
{
Value = new StringInfo
{
Token = 7,
StringId = 0x12345678u,
TableId = 0x23000001u,
Override = StringInfoOverrideFlag.AutoGen,
English = 2,
Comment = 3,
},
};
var color = new ColorBaseProperty
{
Value = new ColorARGB { Blue = 1, Green = 2, Red = 3, Alpha = 4 },
};
var convertedText = LayoutImporter.ConvertProperty(text);
var convertedColor = LayoutImporter.ConvertProperty(color);
Assert.Equal(
new UiStringInfoValue(7, 0x12345678u, 0x23000001u, 2, 2, 3),
convertedText.StringInfoValue);
Assert.Equal(new UiColorValue(1, 2, 3, 4), convertedColor.ColorValue);
}
private static UiPropertyValue Bool(bool value)
=> new() { Kind = UiPropertyKind.Bool, BoolValue = value };
private static UiPropertyValue Enum(uint value)
=> new() { Kind = UiPropertyKind.Enum, UnsignedValue = value };
private static UiStateInfo State(
uint id,
string name,
params (uint Id, UiPropertyValue Value)[] properties)
{
var state = new UiStateInfo { Id = id, Name = name };
foreach (var property in properties)
state.Properties.Values[property.Id] = property.Value;
return state;
}
}

View file

@ -16,7 +16,10 @@ public class VitalsBindingTests
public void Bind_SetsHealthMeterFillFromProvider()
{
var health = new UiMeter();
var layout = FakeLayout((VitalsController.Health, health));
var healthText = new UiText();
var layout = FakeLayout(
(VitalsController.Health, health),
(VitalsController.HealthText, healthText));
float hp = 0.42f;
VitalsController.Bind(layout,
@ -28,9 +31,9 @@ public class VitalsBindingTests
manaText: () => "");
Assert.Equal(0.42f, health.Fill()!.Value);
// The meter no longer draws its own label; the cur/max is a centered UiText child.
// The meter no longer draws its own label; the authored text node is bound in place.
Assert.Null(health.Label());
Assert.Equal("42/100", NumberText(health));
Assert.Equal("42/100", NumberText(healthText));
}
// ── Test 2: All three meters wired to distinct providers ──────────────────
@ -41,10 +44,16 @@ public class VitalsBindingTests
var health = new UiMeter();
var stamina = new UiMeter();
var mana = new UiMeter();
var healthText = new UiText();
var staminaText = new UiText();
var manaText = new UiText();
var layout = FakeLayout(
(VitalsController.Health, health),
(VitalsController.Stamina, stamina),
(VitalsController.Mana, mana));
(VitalsController.Mana, mana),
(VitalsController.HealthText, healthText),
(VitalsController.StaminaText, staminaText),
(VitalsController.ManaText, manaText));
VitalsController.Bind(layout,
healthPct: () => 0.25f,
@ -56,13 +65,13 @@ public class VitalsBindingTests
// Each meter should reflect its own provider, not another's.
Assert.Equal(0.25f, health.Fill()!.Value);
Assert.Equal("25/100", NumberText(health));
Assert.Equal("25/100", NumberText(healthText));
Assert.Equal(0.50f, stamina.Fill()!.Value);
Assert.Equal("50/100", NumberText(stamina));
Assert.Equal("50/100", NumberText(staminaText));
Assert.Equal(0.75f, mana.Fill()!.Value);
Assert.Equal("75/100", NumberText(mana));
Assert.Equal("75/100", NumberText(manaText));
}
// ── Test 3: Missing meter ids are silently skipped (no throw) ─────────────
@ -89,12 +98,12 @@ public class VitalsBindingTests
// ── Helpers ───────────────────────────────────────────────────────────────
/// <summary>The cur/max text from the centered <see cref="UiText"/> number that
/// <see cref="VitalsController"/> attaches as the meter's child.</summary>
private static string NumberText(UiMeter m)
/// <summary>The cur/max text from the authored <see cref="UiText"/> node.</summary>
private static string NumberText(UiText num)
{
var num = Assert.IsType<UiText>(m.Children[0]);
Assert.True(num.Centered);
Assert.True(num.OneLine);
Assert.False(num.Selectable);
var lines = num.LinesProvider();
return lines.Count > 0 ? lines[0].Text : "";
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,39 @@
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public sealed class UiButtonStateMachineTests
{
public static TheoryData<UiButtonVisualInput, uint> RequestedCases
=> new()
{
{ new(false, false, false, false, false), UiButtonStateMachine.Normal },
{ new(false, false, true, false, true), UiButtonStateMachine.NormalRollover },
{ new(false, false, true, true, false), UiButtonStateMachine.NormalRollover },
{ new(false, false, true, true, true), UiButtonStateMachine.NormalPressed },
{ new(false, true, false, false, false), UiButtonStateMachine.Highlight },
{ new(false, true, true, false, true), UiButtonStateMachine.HighlightRollover },
{ new(false, true, true, true, false), UiButtonStateMachine.HighlightRollover },
{ new(false, true, true, true, true), UiButtonStateMachine.HighlightPressed },
{ new(true, false, true, true, true), UiButtonStateMachine.Ghosted },
{ new(true, true, true, true, true), UiButtonStateMachine.Ghosted },
};
[Theory]
[MemberData(nameof(RequestedCases))]
public void RequestedState_MatchesRetailPrecedence(UiButtonVisualInput input, uint expected)
=> Assert.Equal(expected, UiButtonStateMachine.RequestedState(input));
[Fact]
public void ResolveState_MissingRequestedState_PreservesCustomState()
{
var available = new HashSet<uint> { 0x10000053u };
uint resolved = UiButtonStateMachine.ResolveState(
0x10000053u,
new UiButtonVisualInput(false, false, true, false, true),
available);
Assert.Equal(0x10000053u, resolved);
}
}

View file

@ -22,4 +22,140 @@ public class UiButtonTests
var b = new UiButton(new ElementInfo { Type = 1 }, NoTex);
Assert.False(b.ClickThrough);
}
[Fact]
public void PointerTransitions_UseRetailNormalStates()
{
var b = ButtonWithStates("Normal", "Normal_rollover", "Normal_pressed");
b.OnEvent(new UiEvent(0, b, UiEventType.HoverEnter));
Assert.Equal("Normal_rollover", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
Assert.Equal("Normal_pressed", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseMove, Data1: 50, Data2: 50));
Assert.Equal("Normal_rollover", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseMove, Data1: 5, Data2: 5));
Assert.Equal("Normal_pressed", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
Assert.Equal("Normal_rollover", b.ActiveState);
}
[Fact]
public void ToggleRelease_SelectsHighlightState()
{
var info = ButtonInfo("Normal", "Highlight");
AddBoolProperty(info, 0x0Bu, true);
var b = CreateButton(info);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
Assert.True(b.Selected);
Assert.Equal("Highlight", b.ActiveState);
}
[Fact]
public void DisabledProperty_SelectsGhostedAndSuppressesClick()
{
var info = ButtonInfo("Normal", "Ghosted");
AddBoolProperty(info, 0x0Du, true);
var b = CreateButton(info);
b.OnClick = () => _clicked = true;
b.OnEvent(new UiEvent(0, b, UiEventType.Click));
Assert.False(b.Enabled);
Assert.Equal("Ghosted", b.ActiveState);
Assert.False(_clicked);
}
[Fact]
public void MissingStandardState_PreservesCustomSemanticState()
{
var info = ButtonInfo("LockedUI");
info.DefaultStateName = "LockedUI";
var b = CreateButton(info);
b.OnEvent(new UiEvent(0, b, UiEventType.HoverEnter));
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
Assert.Equal("LockedUI", b.ActiveState);
}
[Fact]
public void HotClick_FiresImmediatelyRepeatsAndSuppressesReleaseClick()
{
var info = ButtonInfo("Normal");
AddBoolProperty(info, 0x0Fu, true);
AddFloatProperty(info, 0x10u, 0.10f);
AddFloatProperty(info, 0x11u, 0.05f);
int clicks = 0;
var b = CreateButton(info);
b.OnClick = () => clicks++;
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
Assert.Equal(1, clicks);
b.OnGlobalUiTime(1.00);
b.OnGlobalUiTime(1.09);
Assert.Equal(1, clicks);
b.OnGlobalUiTime(1.11);
Assert.Equal(2, clicks);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
b.OnEvent(new UiEvent(0, b, UiEventType.Click, Data1: 5, Data2: 5));
Assert.Equal(2, clicks);
}
private static UiButton ButtonWithStates(params string[] states)
{
var info = ButtonInfo(states);
AddBoolProperty(info, 0x13u, true);
return CreateButton(info);
}
private static ElementInfo ButtonInfo(params string[] states)
{
var info = new ElementInfo { Type = 1, Width = 20, Height = 20 };
uint file = 1;
foreach (string state in states)
info.StateMedia[state] = (file++, 1);
if (states.Length > 0)
info.DefaultStateName = states[0];
return info;
}
private static void AddBoolProperty(ElementInfo info, uint id, bool value)
{
if (!info.States.TryGetValue(UiStateInfo.DirectStateId, out var state))
{
state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
info.States[UiStateInfo.DirectStateId] = state;
}
state.Properties.Values[id] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = value,
};
}
private static void AddFloatProperty(ElementInfo info, uint id, float value)
{
if (!info.States.TryGetValue(UiStateInfo.DirectStateId, out var state))
{
state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
info.States[UiStateInfo.DirectStateId] = state;
}
state.Properties.Values[id] = new UiPropertyValue
{
Kind = UiPropertyKind.Float,
FloatValue = value,
};
}
private static UiButton CreateButton(ElementInfo info)
=> new(info, NoTex) { Width = info.Width, Height = info.Height };
}

View file

@ -0,0 +1,132 @@
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public sealed class UiLayoutPolicyTests
{
private static readonly UiPixelRect OriginalParent =
UiPixelRect.FromPositionAndSize(0, 0, 800, 600);
private static readonly UiPixelRect OriginalChild =
UiPixelRect.FromPositionAndSize(100, 50, 200, 100);
[Fact]
public void ModeZero_PreservesEveryCurrentEdge()
{
var current = new UiPixelRect(111, 66, 333, 177);
var resizedParent = UiPixelRect.FromPositionAndSize(0, 0, 1600, 900);
var result = UiLayoutPolicy.Apply(
0, 0, 0, 0,
OriginalChild,
OriginalParent,
current,
resizedParent);
Assert.Equal(current, result);
}
[Fact]
public void ModeOne_KeepsNearEdgesAndAddsParentDeltaToFarEdges()
{
var resizedParent = UiPixelRect.FromPositionAndSize(0, 0, 1000, 700);
var result = UiLayoutPolicy.Apply(
1, 1, 1, 1,
OriginalChild,
OriginalParent,
OriginalChild,
resizedParent);
Assert.Equal(new UiPixelRect(100, 50, 499, 249), result);
Assert.Equal(400, result.Width);
Assert.Equal(200, result.Height);
}
[Fact]
public void MixedModeTwoAndOne_MovesFixedSizeChildWithFarSide()
{
var resizedParent = UiPixelRect.FromPositionAndSize(0, 0, 1000, 700);
var result = UiLayoutPolicy.Apply(
2, 2, 1, 1,
OriginalChild,
OriginalParent,
OriginalChild,
resizedParent);
Assert.Equal(new UiPixelRect(300, 150, 499, 249), result);
Assert.Equal(OriginalChild.Width, result.Width);
Assert.Equal(OriginalChild.Height, result.Height);
}
[Fact]
public void ModeThree_UsesInclusivePixelCenterFormula()
{
var resizedParent = UiPixelRect.FromPositionAndSize(0, 0, 1001, 701);
var result = UiLayoutPolicy.Apply(
3, 3, 3, 3,
OriginalChild,
OriginalParent,
OriginalChild,
resizedParent);
Assert.Equal(new UiPixelRect(400, 300, 599, 399), result);
}
[Fact]
public void ModeFour_ScalesEachCoordinateWithTruncationTowardZero()
{
var originalParent = UiPixelRect.FromPositionAndSize(0, 0, 10, 10);
var originalChild = new UiPixelRect(-3, -3, -1, -1);
var resizedParent = UiPixelRect.FromPositionAndSize(0, 0, 15, 15);
var result = UiLayoutPolicy.Apply(
4, 4, 4, 4,
originalChild,
originalParent,
originalChild,
resizedParent);
Assert.Equal(new UiPixelRect(-4, -4, -1, -1), result);
}
[Fact]
public void AssigningCompatibilityAnchors_OptsOutOfImportedPolicy()
{
var element = new UiPanel
{
LayoutPolicy = new UiLayoutPolicy(
1, 1, 1, 1,
OriginalChild,
OriginalParent),
};
element.Anchors = AnchorEdges.Left | AnchorEdges.Top;
Assert.Null(element.LayoutPolicy);
}
[Fact]
public void ApplyAnchor_UsesImportedPolicyInsteadOfCompatibilityMargins()
{
var element = new UiPanel
{
Left = 100,
Top = 50,
Width = 200,
Height = 100,
LayoutPolicy = new UiLayoutPolicy(
1, 1, 1, 1,
OriginalChild,
OriginalParent),
};
element.ApplyAnchor(1000, 700);
Assert.Equal(100f, element.Left);
Assert.Equal(50f, element.Top);
Assert.Equal(400f, element.Width);
Assert.Equal(200f, element.Height);
}
}

View file

@ -482,4 +482,99 @@ public class UiRootInputTests
Assert.True(clicked.ZOrder > peer.ZOrder);
root.OnMouseUp(UiMouseButton.Left, 50, 50);
}
[Fact]
public void AddChild_AssignsEventIdsRecursively()
{
var root = new UiRoot { Width = 800, Height = 600 };
var parent = new UiPanel { Width = 100, Height = 100 };
var child = new UiPanel { Width = 50, Height = 50 };
var grandchild = new UiPanel { Width = 25, Height = 25 };
child.AddChild(grandchild);
parent.AddChild(child);
root.AddChild(parent);
Assert.NotEqual(0u, parent.EventId);
Assert.NotEqual(0u, child.EventId);
Assert.NotEqual(0u, grandchild.EventId);
Assert.Equal(3, new[] { parent.EventId, child.EventId, grandchild.EventId }.Distinct().Count());
}
[Fact]
public void RemovingSubtree_ClearsAllRootOwnership()
{
var root = new UiRoot { Width = 800, Height = 600 };
var window = new UiPanel { Width = 200, Height = 100 };
var field = new UiField { Width = 100, Height = 20 };
window.AddChild(field);
root.AddChild(window);
root.RegisterWindow("test", window);
root.DefaultTextInput = field;
root.Modal = window;
root.SetKeyboardFocus(field);
root.SetCapture(field);
Assert.True(root.RemoveChild(window));
Assert.Null(root.KeyboardFocus);
Assert.Null(root.Captured);
Assert.Null(root.DefaultTextInput);
Assert.Null(root.Modal);
Assert.False(root.ShowWindow("test"));
}
[Fact]
public void Tick_ChecksTooltipBeforeGlobalTimeMessage_AtRetailDelay()
{
var root = new UiRoot { Width = 800, Height = 600 };
var recorder = new TimeOrderRecorder { Width = 100, Height = 100 };
root.AddChild(recorder);
root.Tick(0, 1_000);
root.OnMouseMove(10, 10);
recorder.Events.Clear();
root.Tick(0, 1_249);
Assert.DoesNotContain("tooltip", recorder.Events);
recorder.Events.Clear();
root.Tick(0, 1_250);
Assert.Equal(new[] { "tooltip", "global" }, recorder.Events);
}
[Fact]
public void Tick_GlobalTimeRemoval_SkipsDetachedSiblingWithoutInvalidatingWalk()
{
var root = new UiRoot { Width = 800, Height = 600 };
var victim = new TimeOrderRecorder { Width = 100, Height = 100 };
var remover = new GlobalTimeAction(() => root.RemoveChild(victim))
{ Width = 100, Height = 100 };
root.AddChild(remover);
root.AddChild(victim);
root.Tick(0, 1_000);
Assert.Empty(victim.Events);
Assert.Null(victim.Parent);
}
private sealed class TimeOrderRecorder : UiElement, IUiGlobalTimeListener
{
public List<string> Events { get; } = new();
public override bool OnEvent(in UiEvent e)
{
if (e.Type != UiEventType.Tooltip) return false;
Events.Add("tooltip");
return true;
}
public void OnGlobalUiTime(double nowSeconds) => Events.Add("global");
}
private sealed class GlobalTimeAction(Action action) : UiElement, IUiGlobalTimeListener
{
public void OnGlobalUiTime(double nowSeconds) => action();
}
}