diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
index 3746befa..af6abb63 100644
--- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -538,11 +538,13 @@ public static class DatWidgetFactory
// The absorbed Type-12 caption child (gmPowerbarUI's 0x10000035)
// authors the per-mode caption on its own states — JumpMode
- // 'Height', MeleeMode 'Power', MissileMode 'Accuracy' (HJustify
- // Center over the full bar, matching UiMeter's centered label
- // draw). Retail shows it through the meter's PassToChildren state
- // cascade; the absorbed equivalent is a state-label table
- // consulted by TrySetRetailState.
+ // 'Height', MeleeMode 'Power', MissileMode 'Accuracy'. Each mode
+ // STATE also authors its own justification (0x14 = 0x3 Right on
+ // every powerbar mode; the element default is centered —
+ // installed-DAT probe 2026-08-14, the user's retail gate). Retail
+ // shows it through the meter's PassToChildren state cascade; the
+ // absorbed equivalent is a state-label table consulted by
+ // TrySetRetailState.
foreach (ElementInfo textChild in info.Children.Where(static c => c.Type == 12))
{
foreach (var (stateId, state) in textChild.States)
@@ -551,8 +553,27 @@ public static class DatWidgetFactory
|| !state.Properties.Values.TryGetValue(0x17u, out var caption)
|| caption.Kind != UiPropertyKind.StringInfo)
continue;
- if (stringResolve?.Invoke(caption.StringInfoValue) is { Length: > 0 } text)
- m.ConfigureStateLabel(stateId, text);
+ if (stringResolve?.Invoke(caption.StringInfoValue) is not { Length: > 0 } text)
+ continue;
+ // The state's own 0x14 wins; absent → the element-level
+ // justification (ElementReader's same enum mapping).
+ UiMeterLabelAlign align = textChild.HJustify switch
+ {
+ HJustify.Left => UiMeterLabelAlign.Left,
+ HJustify.Right => UiMeterLabelAlign.Right,
+ _ => UiMeterLabelAlign.Center,
+ };
+ if (state.Properties.Values.TryGetValue(0x14u, out var justify)
+ && justify.Kind == UiPropertyKind.Enum)
+ {
+ align = justify.UnsignedValue switch
+ {
+ 0u or 2u => UiMeterLabelAlign.Left,
+ 3u or 5u => UiMeterLabelAlign.Right,
+ _ => UiMeterLabelAlign.Center,
+ };
+ }
+ m.ConfigureStateLabel(stateId, text, align);
}
}
}
diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs
index e667b4b2..db74eaaf 100644
--- a/src/AcDream.App/UI/UiMeter.cs
+++ b/src/AcDream.App/UI/UiMeter.cs
@@ -2,6 +2,11 @@ using System.Numerics;
namespace AcDream.App.UI;
+/// Justification of a meter's absorbed state caption (dat property
+/// 0x14 on the caption child's own state: 0/2 = Left, 3/5 = Right, 1 = Center
+/// — the same mapping ElementReader applies at element level).
+public enum UiMeterLabelAlign : byte { Left = 0, Center = 1, Right = 2 }
+
///
/// A horizontal vital bar (retail HP/Stamina/Mana style): a background rect, a
/// partial-width solid fill, and an optional centered "current/max" numeric
@@ -17,8 +22,8 @@ namespace AcDream.App.UI;
public sealed class UiMeter : UiElement, IUiDatStateful
{
private readonly Dictionary _stateFillSprites = new();
- private readonly Dictionary _stateLabels = new();
- private string? _activeStateLabel;
+ private readonly Dictionary _stateLabels = new();
+ private (string Text, UiMeterLabelAlign Align)? _activeStateLabel;
/// Dat element id, set by the layout importer so duplicated page copies can be scoped.
public uint ElementId { get; set; }
@@ -76,7 +81,10 @@ public sealed class UiMeter : UiElement, IUiDatStateful
/// The caption latched by the active retail state (null when the
/// state authors none). Exposed for tests.
- internal string? ActiveStateLabel => _activeStateLabel;
+ internal string? ActiveStateLabel => _activeStateLabel?.Text;
+
+ /// The latched caption's authored justification. Exposed for tests.
+ internal UiMeterLabelAlign? ActiveStateLabelAlign => _activeStateLabel?.Align;
///
/// Registers a fill sprite carried by a child state of the imported meter.
@@ -95,19 +103,21 @@ public sealed class UiMeter : UiElement, IUiDatStateful
/// / MissileMode 'Accuracy'). Retail shows it via the meter's
/// PassToChildren state cascade; the absorbed equivalent latches it in
/// and draws it when no live
- /// provider supplies text. The child authors
- /// HJustify=Center — the same centered draw the label overlay uses.
+ /// provider supplies text. Each mode STATE authors its
+ /// own justification (dat property 0x14 — the powerbar modes author 0x3 =
+ /// Right; only the element default is centered), so alignment rides the
+ /// state entry.
///
- internal void ConfigureStateLabel(uint stateId, string text)
+ internal void ConfigureStateLabel(uint stateId, string text, UiMeterLabelAlign align)
{
if (stateId != 0 && !string.IsNullOrEmpty(text))
- _stateLabels[stateId] = text;
+ _stateLabels[stateId] = (text, align);
}
public bool TrySetRetailState(uint stateId)
{
bool hasFill = _stateFillSprites.TryGetValue(stateId, out uint spriteId);
- bool hasLabel = _stateLabels.TryGetValue(stateId, out string? caption);
+ bool hasLabel = _stateLabels.TryGetValue(stateId, out var caption);
if (!hasFill && !hasLabel)
return false;
@@ -136,6 +146,14 @@ public sealed class UiMeter : UiElement, IUiDatStateful
/// grandchild slice/text elements as separate widgets.
public override bool ConsumesDatChildren => true;
+ private static float AlignX(UiMeterLabelAlign align, float width, float textWidth)
+ => align switch
+ {
+ UiMeterLabelAlign.Left => 0f,
+ UiMeterLabelAlign.Right => width - textWidth,
+ _ => (width - textWidth) * 0.5f,
+ };
+
/// Clamp to [0,1] and return the fill rect
/// (local px) for a bar of x .
public static (float x, float y, float w, float h) ComputeFillRect(
@@ -196,16 +214,20 @@ public sealed class UiMeter : UiElement, IUiDatStateful
}
}
- // A live provider (vitals "cur/max") wins; otherwise the active
- // retail-state caption ('Height'/'Power'/'Accuracy') shows.
- string? label = Label() ?? _activeStateLabel;
+ // A live provider (vitals "cur/max") wins and stays centered;
+ // otherwise the active retail-state caption shows with its own
+ // authored justification (the powerbar modes author Right).
+ string? label = Label();
+ UiMeterLabelAlign align = UiMeterLabelAlign.Center;
+ if (string.IsNullOrEmpty(label) && _activeStateLabel is { } stateLabel)
+ (label, align) = stateLabel;
if (!string.IsNullOrEmpty(label))
{
if (DatFont is { } datFont)
{
- // Retail path: centered cur/max via the dat font's two-pass blit.
+ // Retail path: the dat font's two-pass blit.
float tw = datFont.MeasureWidth(label);
- float tx = (Width - tw) * 0.5f;
+ float tx = AlignX(align, Width, tw);
float ty = (Height - datFont.LineHeight) * 0.5f;
ctx.DrawStringDat(datFont, label, tx, ty, LabelColor, Outline, OutlineColor);
}
@@ -213,7 +235,7 @@ public sealed class UiMeter : UiElement, IUiDatStateful
{
// Fallback: debug bitmap font (no dat font available).
float tw = font.MeasureWidth(label);
- float tx = (Width - tw) * 0.5f;
+ float tx = AlignX(align, Width, tw);
float ty = (Height - font.LineHeight) * 0.5f;
ctx.DrawString(label, tx, ty, LabelColor);
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
index 10a7f96a..232c9fa9 100644
--- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
@@ -755,6 +755,13 @@ public class DatWidgetFactoryTests
Kind = UiPropertyKind.StringInfo,
StringInfoValue = new UiStringInfoValue(0, 1, 2, 0, 1, 0),
};
+ // The mode state's own justification (0x14=0x3 Right — the powerbar
+ // modes all author it; the element default stays centered).
+ jumpText.Properties.Values[0x14u] = new UiPropertyValue
+ {
+ Kind = UiPropertyKind.Enum,
+ UnsignedValue = 0x3u,
+ };
caption.States[0x10000042u] = jumpText;
meter.Children.Add(caption);
@@ -764,6 +771,7 @@ public class DatWidgetFactoryTests
Assert.Null(m.ActiveStateLabel);
Assert.True(m.TrySetRetailState(0x10000042u));
Assert.Equal("Height", m.ActiveStateLabel);
+ Assert.Equal(UiMeterLabelAlign.Right, m.ActiveStateLabelAlign);
Assert.Equal(0x06001354u, m.FrontTile);
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs
index 736a3432..531b6f35 100644
--- a/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs
@@ -67,9 +67,15 @@ public sealed class PowerbarLayoutProbeTests
text = strings.Resolve(p.StringInfoValue) ?? "";
text = $" text='{text}'";
}
+ string properties = string.Join(
+ " ",
+ state.Properties.Values
+ .OrderBy(static pair => pair.Key)
+ .Select(static pair =>
+ $"0x{pair.Key:X2}:{pair.Value.Kind}=0x{pair.Value.UnsignedValue:X}"));
Console.WriteLine(
$"[pbprobe] {indent} state 0x{stateId:X8} '{state.Name}'"
- + $" passToChildren={state.PassToChildren}{text}");
+ + $" passToChildren={state.PassToChildren}{text} props[{properties}]");
}
foreach (ElementInfo child in e.Children)
DumpElement(strings, child, depth + 1);