diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs
index 787622da..e686c481 100644
--- a/src/AcDream.App/UI/Layout/CharacterStatController.cs
+++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs
@@ -140,11 +140,12 @@ public static class CharacterStatController
private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f);
// ── Row layout constants ─────────────────────────────────────────────────
- // RowHeight reduced from 44 to 30px to match retail's tighter attribute list.
- // The larger row font (0x40000001, MaxCharHeight=18) fits in 30px with 6px total vertical
- // padding (3px above/below). Retail spec (2026-06-26 ref): "rows tighter, text ≈ icon height".
- private const float RowHeight = 30f;
- private const float IconSize = 24f;
+ // RowHeight 22px + IconSize 16px: retail spec (2026-06-26) says icons ~icon-height
+ // and rows tighter. 16px icon fits inside 22px row with 3px vertical padding each side.
+ // The larger row font (0x40000001, MaxCharHeight=18) is clipped to the 22px height which
+ // gives a tight-but-readable line. Retail spec (2026-06-26 ref): "rows tighter, text ≈ icon height".
+ private const float RowHeight = 22f;
+ private const float IconSize = 16f;
private const float RowPadX = 4f;
private const float IconGap = 6f;
@@ -242,7 +243,22 @@ public static class CharacterStatController
{
if (datFont is not null) xpLabel.DatFont = datFont;
xpLabel.ClickThrough = true;
+ xpLabel.Centered = false; // left-align (retail: aligns with Total XP label above)
+ xpLabel.RightAligned = false;
xpLabel.Padding = 0f; // avoid scroll clip — meter bar is ~13px tall
+
+ // Item 1: align the XP-next label's left edge to match the TotalXpLabel's
+ // absolute left edge. The XP-next label is a child of the meter (local coords),
+ // so its Left = TotalXpLabel.Left − meter.Left. This accounts for the meter's
+ // horizontal offset within the panel (the meter starts to the right of the
+ // "Total Experience (XP):" caption row). Source: retail spec §State 1 (the
+ // "XP for next level:" caption left-aligns with "Total Experience (XP):" above).
+ if (layout.FindElement(TotalXpLabelId) is { } totalXpLbl)
+ {
+ float xpNextLeft = totalXpLbl.Left - meter.Left;
+ xpLabel.Left = xpNextLeft >= 0f ? xpNextLeft : 0f;
+ }
+
xpLabel.LinesProvider = static () => new[] { new UiText.Line("XP for next level:", Body) };
}
if (layout.FindElement(XpNextValueId) is UiText xpValue)
@@ -605,13 +621,14 @@ public static class CharacterStatController
{
var row = new UiClickablePanel
{
- Left = left,
- Top = top,
- Width = width,
- Height = height,
- BackgroundColor = Vector4.Zero, // transparent until selected
- BorderColor = Vector4.Zero,
- Anchors = AnchorEdges.Left | AnchorEdges.Top,
+ Left = left,
+ Top = top,
+ Width = width,
+ Height = height,
+ BackgroundColor = Vector4.Zero, // transparent until selected
+ BorderColor = Vector4.Zero,
+ UseSelectionBars = true, // Item 3: draw sprite as top+bottom bars, not full stretch
+ Anchors = AnchorEdges.Left | AnchorEdges.Top,
};
float iconY = (height - IconSize) * 0.5f;
diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs
index a927ec57..de31ecda 100644
--- a/src/AcDream.App/UI/UiPanel.cs
+++ b/src/AcDream.App/UI/UiPanel.cs
@@ -124,12 +124,30 @@ public class UiSimpleButton : UiPanel
/// catches UIEvent_LeftClick (0x01) and calls SetSelectedAttribute on the
/// parent window. In acdream we wire the equivalent via this action callback instead of
/// the retail message bus.
+///
+/// When is true and
+/// is non-zero, draws the sprite as a thin full-width bar at the TOP and BOTTOM edges of
+/// the row (not stretched to fill). This matches retail's selection highlight which shows
+/// a horizontal dark bar on both the top and bottom edge of the selected attribute row,
+/// with NO left/right end-caps. Bar height is pixels
+/// (default 3px).
///
public class UiClickablePanel : UiPanel
{
/// Called when the user releases the left mouse button over this panel.
public Action? OnClick { get; set; }
+ /// When true and is non-zero, draws
+ /// the sprite as a thin horizontal bar at the top AND bottom edges of the panel,
+ /// NOT as a full-height stretched fill. Matches retail's selected-row highlight
+ /// (sprite 0x06001397 — 300×32 px — shown as bars, not a block fill).
+ /// Default false (preserves legacy full-stretch behavior).
+ public bool UseSelectionBars { get; set; }
+
+ /// Height in pixels of each selection bar (top and bottom). Default 3px.
+ /// Ignored when is false.
+ public float SelectionBarHeight { get; set; } = 3f;
+
public UiClickablePanel()
{
// Rows must receive pointer events — override the UiPanel default (ClickThrough=false,
@@ -152,4 +170,33 @@ public class UiClickablePanel : UiPanel
}
return false;
}
+
+ protected override void OnDraw(UiRenderContext ctx)
+ {
+ if (UseSelectionBars && BackgroundSprite != 0 && SpriteResolve is { } sr)
+ {
+ // Draw the selection highlight as a thin bar at the TOP and BOTTOM of the row.
+ // The sprite (0x06001397) is 300×32 px — we draw it as horizontal strips at
+ // native height (SelectionBarHeight), stretched to full panel width (UV tile
+ // horizontally). No left/right end-caps: u0=0, u1=Width/nativeW (UV repeat).
+ var (tex, tw, th) = sr(BackgroundSprite);
+ if (tex != 0 && tw > 0 && th > 0)
+ {
+ float barH = SelectionBarHeight;
+ float uTile = tw > 0 ? Width / tw : 1f;
+ // Top bar: shows the top barH px of the sprite (v = 0 → barH/th).
+ float vBot = th > 0 ? barH / th : 1f;
+ ctx.DrawSprite(tex, 0f, 0f, Width, barH, 0f, 0f, uTile, vBot, Vector4.One);
+ // Bottom bar: shows the bottom barH px of the sprite (v = 1−barH/th → 1).
+ float vTop2 = th > 0 ? 1f - barH / th : 0f;
+ ctx.DrawSprite(tex, 0f, Height - barH, Width, barH, 0f, vTop2, uTile, 1f, Vector4.One);
+ }
+ // Selection-bar mode draws no border (rows have BorderColor=Zero by design).
+ }
+ else
+ {
+ // Default UiPanel draw: handles BackgroundSprite, BackgroundColor, AND border.
+ base.OnDraw(ctx);
+ }
+ }
}