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)