From 88cdfdc3c7317751db66ab2f40b8039e640737d9 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 08:22:03 +0200 Subject: [PATCH] fix: jump-bar 'Height' right-aligned - per-STATE justification (user gate; the DAT authors it) The raw-property probe settles it exactly as the user reported: the powerbar caption's mode STATES author their OWN justification (0x14 Enum=0x3 = Right on JumpMode/MeleeMode/MissileMode) while the element default stays centered. ElementInfo.HJustify only ever read the effective DEFAULT state, so the earlier "authored Center" conclusion measured the wrong state. The meter's absorbed state-label entry now carries the state's own authored alignment (state 0x14 wins, element-level HJustify as the fallback, ElementReader's same enum mapping), and the caption draw aligns accordingly - 'Height' sits at the bar's right edge, retail's placement. Live vitals labels keep their centered draw. App suite 4,987/3 skips. Co-Authored-By: Claude Fable 5 --- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 35 ++++++++++--- src/AcDream.App/UI/UiMeter.cs | 50 +++++++++++++------ .../UI/Layout/DatWidgetFactoryTests.cs | 8 +++ .../UI/Layout/PowerbarLayoutProbeTests.cs | 8 ++- 4 files changed, 79 insertions(+), 22 deletions(-) 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);