diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs
index 78966be8..7d28c711 100644
--- a/src/AcDream.App/UI/Layout/CharacterStatController.cs
+++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs
@@ -747,22 +747,22 @@ public static class CharacterStatController
var titleEl = ByPos(0f, 0f, FooterTitleId);
if (titleEl is not null)
{
+ // The dat title element is H=55 (the full footer box). The dat says VJustify=Center, so
+ // without an override the text would center vertically in the 55px box, overlapping
+ // line-1/line-2 below. We set VerticalJustify=Top explicitly so the text renders at the
+ // top of the 55px box (y≈Padding), keeping all three footer lines non-overlapping.
+ // The dat says HJustify=Center (Centered=true from BuildText) — the title is centered.
+ // BackgroundSprite cleared: its full-height sprite would cover line-1/line-2.
titleEl.BackgroundSprite = 0;
- // The dat title element is H=55 (the full footer box). Centering the title text in it
- // lands dead-center (~y572), overlapping line-1/line-2 (y565/y582 — confirmed by a
- // position dump). Constrain it to a thin line at the footer top, and drop its anchors
- // so the per-frame stretch pass doesn't re-grow it back to 55.
- titleEl.Height = 18f;
- titleEl.Anchors = AnchorEdges.None;
+ titleEl.VerticalJustify = VJustify.Top; // dat says Center; override to Top (see comment above)
}
// Title color: State A = body (parchment); State B = WHITE per retail (confirmed 2026-06-26 ref).
if (titleEl is not null)
{
titleEl.DatFont = datFont;
- titleEl.Centered = false;
- titleEl.RightAligned = false;
+ // Centered=true comes from the dat (HJustify=Center) via BuildText — not overridden here.
+ // RightAligned stays false (BuildText default for a Center element).
titleEl.ClickThrough = true;
- titleEl.Padding = 0f;
titleEl.LinesProvider = () =>
{
if (sel[0] < 0)
diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
index 125f174a..ebc19b0b 100644
--- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -239,13 +239,43 @@ public static class DatWidgetFactory
/// 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
- /// because defaults to transparent.
+ /// because defaults to transparent.
+ ///
+ ///
+ /// Justification from the dat ( /
+ /// ) is applied here at build time so that controllers
+ /// that subsequently call /
+ /// on dat-origin elements can be simplified. Controllers that explicitly set those
+ /// properties after still override the build-time
+ /// defaults — the build-time value is just the starting point, not a lock.
+ ///
+ ///
private static UiText BuildText(ElementInfo info, Func resolve)
{
uint bg = info.StateMedia.TryGetValue(
!string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName
: info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m)
? m.File : 0u;
- return new UiText { BackgroundSprite = bg, SpriteResolve = resolve };
+
+ // 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.
+ bool centered = info.HJustify == HJustify.Center;
+ bool rightAligned = info.HJustify == HJustify.Right;
+ var vJustify = info.VJustify switch
+ {
+ VJustify.Top => VJustify.Top,
+ VJustify.Bottom => VJustify.Bottom,
+ _ => VJustify.Center,
+ };
+
+ return new UiText
+ {
+ BackgroundSprite = bg,
+ SpriteResolve = resolve,
+ Centered = centered,
+ RightAligned = rightAligned,
+ VerticalJustify = vJustify,
+ };
}
}
diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs
index bbafcd05..27883dfd 100644
--- a/src/AcDream.App/UI/Layout/ElementReader.cs
+++ b/src/AcDream.App/UI/Layout/ElementReader.cs
@@ -2,6 +2,18 @@ using System.Collections.Generic;
namespace AcDream.App.UI.Layout;
+///
+/// Horizontal text justification read from dat property 0x14 (UIElement HorizontalJustification).
+/// Values match the retail enum: 0=Left, 1=Center, 3/5=Right.
+///
+public enum HJustify : byte { Left = 0, Center = 1, Right = 2 }
+
+///
+/// Vertical text justification read from dat property 0x15 (UIElement VerticalJustification).
+/// Values: 2=Top, 4=Bottom; absent/other = Center.
+///
+public enum VJustify : byte { Top = 0, Center = 1, Bottom = 2 }
+
///
/// GL-free, dat-free snapshot of a resolved layout element.
/// Populated by the LayoutDesc importer from DatReaderWriter.ElementDesc
@@ -51,6 +63,21 @@ public sealed class ElementInfo
///
public uint FontDid;
+ ///
+ /// Horizontal text justification from dat Properties[0x14]
+ /// (EnumBaseProperty: 0=Left, 1=Center, 3/5=Right).
+ /// Default is to preserve existing behavior where
+ /// controllers set Centered=true and no property was read.
+ ///
+ public HJustify HJustify = HJustify.Center;
+
+ ///
+ /// Vertical text justification from dat Properties[0x15]
+ /// (EnumBaseProperty: 2=Top, 4=Bottom; absent/other = Center).
+ /// Default is to preserve existing behavior.
+ ///
+ public VJustify VJustify = VJustify.Center;
+
///
/// Sprite per state: state name → (RenderSurface file id, DrawMode int).
/// The "" key represents the unnamed DirectState (ElementDesc.StateDesc).
@@ -160,6 +187,12 @@ public static class ElementReader
ReadOrder = derived.ReadOrder,
ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel,
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
+ // HJustify/VJustify: derived wins when it carries an explicit non-Center value
+ // (the dat property was present and read); otherwise inherit the base prototype's value.
+ // Center is the default (= "not set by this element") so Center-derived never overrides
+ // a non-Center base — matching the FontDid "non-zero wins" convention.
+ HJustify = derived.HJustify != HJustify.Center ? derived.HJustify : base_.HJustify,
+ VJustify = derived.VJustify != VJustify.Center ? derived.VJustify : base_.VJustify,
// DefaultStateName: derived wins if set; otherwise inherit the base's default.
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
// Children come from the derived element's own tree, not the base prototype's.
diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs
index 8859f25f..799470f2 100644
--- a/src/AcDream.App/UI/Layout/LayoutImporter.cs
+++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs
@@ -362,6 +362,40 @@ public static class LayoutImporter
{
info.FontDid = did.Value;
}
+
+ if (sd.Properties is not null)
+ {
+ // HorizontalJustification (0x14): EnumBaseProperty.
+ // Retail values: 0=Left, 1=Center, 3=Right, 5=Right (treat 5 as Right per spec).
+ // Only update if still at the default (Center); derived-wins handled in Merge.
+ if (info.HJustify == HJustify.Center
+ && sd.Properties.TryGetValue(0x14u, out var hRaw)
+ && hRaw is EnumBaseProperty hEnum)
+ {
+ info.HJustify = hEnum.Value switch
+ {
+ 0u => HJustify.Left,
+ 1u => HJustify.Center,
+ 3u => HJustify.Right,
+ 5u => HJustify.Right,
+ _ => HJustify.Center, // unknown → center (safe default)
+ };
+ }
+
+ // VerticalJustification (0x15): EnumBaseProperty.
+ // Retail values: 2=Top, 4=Bottom; absent/other = Center.
+ if (info.VJustify == VJustify.Center
+ && sd.Properties.TryGetValue(0x15u, out var vRaw)
+ && vRaw is EnumBaseProperty vEnum)
+ {
+ info.VJustify = vEnum.Value switch
+ {
+ 2u => VJustify.Top,
+ 4u => VJustify.Bottom,
+ _ => VJustify.Center,
+ };
+ }
+ }
}
// ── Prototype detection helpers ───────────────────────────────────────────
diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs
index 6e7222ab..e6bc45f0 100644
--- a/src/AcDream.App/UI/UiText.cs
+++ b/src/AcDream.App/UI/UiText.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Numerics;
using System.Text;
using AcDream.App.Rendering;
+using AcDream.App.UI.Layout;
namespace AcDream.App.UI;
@@ -78,6 +79,23 @@ public sealed class UiText : UiElement
/// precedence. Pair with ClickThrough = true for non-interactive labels.
public bool RightAligned { get; set; }
+ ///
+ /// Vertical position of the text within the element rect in single-line mode
+ /// ( or ).
+ ///
+ /// - Center (default) — vertically centered, matching the original
+ /// behavior of the centered/right-aligned paths.
+ /// - Top — text is placed at y = Padding (top of the content
+ /// area), so the text sits at the top of the element rather than centering in it.
+ /// Used for footer title elements whose dat box is the full footer height (55 px) but
+ /// the text should render near the top.
+ /// - Bottom — text is placed at y = Height - lineHeight - Padding.
+ ///
+ /// Only meaningful when or is true.
+ /// Has no effect on the scrollable multi-line path.
+ ///
+ public VJustify VerticalJustify { get; set; } = VJustify.Center;
+
/// The scroll model — also read by the linked UiScrollbar.
public UiScrollable Scroll { get; } = new();
@@ -138,8 +156,8 @@ public sealed class UiText : UiElement
ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
// Static centered single-line mode (vitals cur/max numbers etc.): draw the first
- // line centered H+V with the SAME formula UIElement_Meter used for its label, then
- // skip the scroll/selection machinery entirely.
+ // 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)
{
var cLines = LinesProvider();
@@ -148,20 +166,20 @@ public sealed class UiText : UiElement
if (DatFont is { } cdf)
{
float cx = (Width - cdf.MeasureWidth(line0.Text)) * 0.5f;
- float cy = (Height - cdf.LineHeight) * 0.5f;
+ float cy = VOffset(Height, cdf.LineHeight, Padding, VerticalJustify);
ctx.DrawStringDat(cdf, line0.Text, cx, cy, line0.Color);
}
else if ((Font ?? ctx.DefaultFont) is { } cbf)
{
float cx = (Width - cbf.MeasureWidth(line0.Text)) * 0.5f;
- float cy = (Height - cbf.LineHeight) * 0.5f;
+ float cy = VOffset(Height, cbf.LineHeight, Padding, VerticalJustify);
ctx.DrawString(line0.Text, cx, cy, line0.Color, cbf);
}
return;
}
// Static right-aligned single-line mode: draw the first line flush with the right
- // edge, vertically centered, then skip the scroll/selection machinery.
+ // edge, vertical position per VerticalJustify, then skip the scroll/selection machinery.
if (RightAligned)
{
var rLines = LinesProvider();
@@ -170,13 +188,13 @@ public sealed class UiText : UiElement
if (DatFont is { } rdf)
{
float rx = Width - rdf.MeasureWidth(line0.Text) - Padding;
- float ry = (Height - rdf.LineHeight) * 0.5f;
+ float ry = VOffset(Height, rdf.LineHeight, Padding, VerticalJustify);
ctx.DrawStringDat(rdf, line0.Text, rx, ry, line0.Color);
}
else if ((Font ?? ctx.DefaultFont) is { } rbf)
{
float rx = Width - rbf.MeasureWidth(line0.Text) - Padding;
- float ry = (Height - rbf.LineHeight) * 0.5f;
+ float ry = VOffset(Height, rbf.LineHeight, Padding, VerticalJustify);
ctx.DrawString(line0.Text, rx, ry, line0.Color, rbf);
}
return;
@@ -366,6 +384,23 @@ public sealed class UiText : UiElement
// ── Pure, testable logic (no GL / no font texture) ───────────────────
+ ///
+ /// Compute the Y offset (local space) for a single line in the Centered/RightAligned
+ /// single-line path, given the element height, font line-height, padding, and
+ /// vertical justification.
+ ///
+ /// Element height in pixels.
+ /// Font line height in pixels.
+ /// Content padding.
+ /// Vertical justification.
+ public static float VOffset(float height, float lineHeight, float padding, VJustify vj)
+ => vj switch
+ {
+ VJustify.Top => padding,
+ VJustify.Bottom => height - lineHeight - padding,
+ _ => (height - lineHeight) * 0.5f, // Center (default)
+ };
+
/// Order two caret positions so the first is <= the second (by line,
/// then column).
public static (Pos start, Pos end) Order(Pos a, Pos b)
diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
index 67a3a10a..0adb9eee 100644
--- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
@@ -228,4 +228,75 @@ public class DatWidgetFactoryTests
Assert.NotEqual(OverlayFile, m.FrontRight);
Assert.NotEqual(OverlayFile, m.FrontTile);
}
+
+ // ── Justification build-time application (new for importer Fix A) ────────
+
+ ///
+ /// A Type-12 text element with HJustify=Center (the default) must produce a
+ /// UiText with Centered=true and RightAligned=false at build time.
+ /// This proves BuildText applies the dat's HJustify at construction without a
+ /// controller binding step.
+ ///
+ [Fact]
+ public void BuildText_HJustifyCenter_SetsCentered()
+ {
+ var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Center };
+ var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null));
+ Assert.True(t.Centered);
+ Assert.False(t.RightAligned);
+ Assert.Equal(VJustify.Center, t.VerticalJustify);
+ }
+
+ ///
+ /// A Type-12 text element with HJustify=Right must produce a UiText with
+ /// Centered=false and RightAligned=true at build time.
+ ///
+ [Fact]
+ public void BuildText_HJustifyRight_SetsRightAligned()
+ {
+ var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Right };
+ var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null));
+ Assert.False(t.Centered);
+ Assert.True(t.RightAligned);
+ }
+
+ ///
+ /// A Type-12 text element with HJustify=Left must produce a UiText with
+ /// Centered=false and RightAligned=false at build time.
+ ///
+ [Fact]
+ public void BuildText_HJustifyLeft_SetsNeitherCenteredNorRight()
+ {
+ var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Left };
+ var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null));
+ Assert.False(t.Centered);
+ Assert.False(t.RightAligned);
+ }
+
+ ///
+ /// A Type-12 text element with VJustify=Top must produce a UiText with
+ /// VerticalJustify=Top at build time.
+ ///
+ [Fact]
+ public void BuildText_VJustifyTop_SetsVerticalJustifyTop()
+ {
+ var info = new ElementInfo { Type = 12, Width = 100, Height = 55, VJustify = VJustify.Top };
+ var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null));
+ Assert.Equal(VJustify.Top, t.VerticalJustify);
+ }
+
+ ///
+ /// A controller override: build-time sets Centered=true (HJustify=Center), then
+ /// a controller sets Centered=false afterward. The controller's override wins —
+ /// this is the backward-compatibility guarantee.
+ ///
+ [Fact]
+ public void BuildText_ControllerOverrideWinsAfterBuild()
+ {
+ var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Center };
+ var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null));
+ Assert.True(t.Centered); // build-time value
+ t.Centered = false; // controller override
+ Assert.False(t.Centered); // override wins
+ }
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs
index 9d79f58d..2745590d 100644
--- a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs
@@ -161,4 +161,57 @@ public class ElementReaderTests
Assert.Single(merged.Children);
Assert.Equal(0x2u, merged.Children[0].Id);
}
+
+ // ── HJustify / VJustify — Merge propagation ─────────────────────────────
+
+ ///
+ /// When the derived element has a non-Center HJustify, the derived value wins
+ /// (same "non-default wins" rule as FontDid).
+ ///
+ [Fact]
+ public void Merge_DerivedHJustifyRight_OverridesBaseCenter()
+ {
+ var base_ = new ElementInfo { HJustify = HJustify.Center };
+ var derived = new ElementInfo { HJustify = HJustify.Right };
+ var merged = ElementReader.Merge(base_, derived);
+ Assert.Equal(HJustify.Right, merged.HJustify);
+ }
+
+ ///
+ /// When the derived element has the default HJustify (Center), the base value
+ /// is inherited — Center from the derived does NOT override a Left base.
+ ///
+ [Fact]
+ public void Merge_DerivedHJustifyCenter_InheritsBaseLeft()
+ {
+ var base_ = new ElementInfo { HJustify = HJustify.Left };
+ var derived = new ElementInfo { HJustify = HJustify.Center }; // default — no explicit dat property
+ var merged = ElementReader.Merge(base_, derived);
+ Assert.Equal(HJustify.Left, merged.HJustify);
+ }
+
+ ///
+ /// VJustify=Top from the base propagates when the derived element has no explicit
+ /// (Center) vertical justification.
+ ///
+ [Fact]
+ public void Merge_BaseVJustifyTop_InheritedWhenDerivedIsCenter()
+ {
+ var base_ = new ElementInfo { VJustify = VJustify.Top };
+ var derived = new ElementInfo { VJustify = VJustify.Center }; // default
+ var merged = ElementReader.Merge(base_, derived);
+ Assert.Equal(VJustify.Top, merged.VJustify);
+ }
+
+ ///
+ /// VJustify=Bottom from the derived element wins over a Center base.
+ ///
+ [Fact]
+ public void Merge_DerivedVJustifyBottom_OverridesBaseCenter()
+ {
+ var base_ = new ElementInfo { VJustify = VJustify.Center };
+ var derived = new ElementInfo { VJustify = VJustify.Bottom };
+ var merged = ElementReader.Merge(base_, derived);
+ Assert.Equal(VJustify.Bottom, merged.VJustify);
+ }
}
diff --git a/tests/AcDream.App.Tests/UI/UiTextTests.cs b/tests/AcDream.App.Tests/UI/UiTextTests.cs
index 691dc213..946f7fad 100644
--- a/tests/AcDream.App.Tests/UI/UiTextTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiTextTests.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.UI;
+using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI;
@@ -140,4 +141,57 @@ public class UiTextTests
Assert.Equal(new UiText.Pos(1, 2), s2);
Assert.Equal(new UiText.Pos(1, 8), e2);
}
+
+ // ── VOffset: vertical positioning for single-line mode ───────────────────
+
+ ///
+ /// VJustify.Center: vertical offset = (height - lineHeight) / 2.
+ /// This is the original formula used by both the Centered and RightAligned paths
+ /// before VerticalJustify was introduced. Must remain unchanged (backward compat).
+ ///
+ [Fact]
+ public void VOffset_Center_ReturnsHalfHeightMinusLineHeight()
+ {
+ float y = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 0f, VJustify.Center);
+ Assert.Equal((55f - 12f) * 0.5f, y);
+ }
+
+ ///
+ /// VJustify.Top: vertical offset = padding (top of the content area).
+ /// Used for footer title elements whose dat box is the full footer height (55 px)
+ /// but the text should render near the top.
+ ///
+ [Fact]
+ public void VOffset_Top_ReturnsPadding()
+ {
+ float y = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 0f, VJustify.Top);
+ Assert.Equal(0f, y);
+
+ float yWithPadding = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 4f, VJustify.Top);
+ Assert.Equal(4f, yWithPadding);
+ }
+
+ ///
+ /// VJustify.Bottom: vertical offset = height - lineHeight - padding.
+ ///
+ [Fact]
+ public void VOffset_Bottom_ReturnsHeightMinusLineHeightMinusPadding()
+ {
+ float y = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 2f, VJustify.Bottom);
+ Assert.Equal(55f - 12f - 2f, y);
+ }
+
+ ///
+ /// Center with non-zero padding: padding does NOT affect the center formula —
+ /// VJustify.Center always uses (height - lineHeight) / 2 regardless of padding,
+ /// matching the original behavior.
+ ///
+ [Fact]
+ public void VOffset_Center_IgnoresPadding()
+ {
+ float yNoPad = UiText.VOffset(height: 40f, lineHeight: 12f, padding: 0f, VJustify.Center);
+ float yWithPad = UiText.VOffset(height: 40f, lineHeight: 12f, padding: 4f, VJustify.Center);
+ Assert.Equal(yNoPad, yWithPad); // Center is padding-independent
+ Assert.Equal((40f - 12f) * 0.5f, yNoPad);
+ }
}