diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs
index 057402c7..5d7fc535 100644
--- a/src/AcDream.App/UI/UiMeter.cs
+++ b/src/AcDream.App/UI/UiMeter.cs
@@ -55,6 +55,14 @@ public sealed class UiMeter : UiElement
/// Coloured-fill right-cap RenderSurface id.
public uint FrontRight { get; set; }
+ /// Vertical orientation (retail m_eDirection 2/4). Default false =
+ /// horizontal (direction 1). The burden bar is the only vertical meter today.
+ public bool Vertical { get; set; }
+
+ /// For a vertical meter, fill grows from the bottom up (retail direction 4)
+ /// when true, top-down (direction 2) when false. Default bottom-up. Visual-confirmed.
+ public bool FillFromBottom { get; set; } = true;
+
public UiMeter() { ClickThrough = true; }
/// The meter draws its own 3-slice bars; the importer must not build its
@@ -71,6 +79,18 @@ public sealed class UiMeter : UiElement
return (0f, 0f, w * pct, h);
}
+ /// Clamp to [0,1] and return the vertical fill rect
+ /// (local px). true → the fill occupies the bottom
+ /// h*pct px (retail direction 4); false → the top (direction 2).
+ public static (float x, float y, float w, float h) ComputeVFillRect(
+ float pct, float w, float h, bool fromBottom)
+ {
+ if (pct < 0f) pct = 0f;
+ if (pct > 1f) pct = 1f;
+ float fh = h * pct;
+ return (0f, fromBottom ? h - fh : 0f, w, fh);
+ }
+
protected override void OnDraw(UiRenderContext ctx)
{
float? pct = Fill();
@@ -78,14 +98,25 @@ public sealed class UiMeter : UiElement
if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0))
{
- // Retail meter (UIElement_Meter::DrawChildren): the BACK 3-slice is the
- // empty track, drawn full width; the FRONT 3-slice is the coloured fill,
- // drawn at FULL width too but horizontally CLIPPED to the fill fraction.
- // The front carries its own right-cap (shown at 100%); clipping below 100%
- // removes it and reveals the back track's right-cap — retail's scissor-fill.
- DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
- if (pct is not null && p > 0f)
- DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
+ if (Vertical)
+ {
+ // Track full height, fill clipped to the fraction along the fill direction.
+ // The burden meter is a single-tile bar (BackTile/FrontTile, no caps).
+ DrawVBar(ctx, resolve, BackTile, Height, fromBottom: FillFromBottom, isFill: false);
+ if (pct is not null && p > 0f)
+ DrawVBar(ctx, resolve, FrontTile, Height * p, fromBottom: FillFromBottom, isFill: true);
+ }
+ else
+ {
+ // Retail meter (UIElement_Meter::DrawChildren): the BACK 3-slice is the
+ // empty track, drawn full width; the FRONT 3-slice is the coloured fill,
+ // drawn at FULL width too but horizontally CLIPPED to the fill fraction.
+ // The front carries its own right-cap (shown at 100%); clipping below 100%
+ // removes it and reveals the back track's right-cap — retail's scissor-fill.
+ DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
+ if (pct is not null && p > 0f)
+ DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
+ }
}
else
{
@@ -158,6 +189,28 @@ public sealed class UiMeter : UiElement
DrawPiece(ctx, rt, w - capR, capR, rw, h, clipW);
}
+ /// Draws a single-tile vertical bar slice. The track (
+ /// false) spans the full height; the fill spans px from the
+ /// bottom () or top, UV-cropped to that fraction of the
+ /// sprite so the fill reveals the matching part of the art (retail
+ /// UIElement_Meter::DrawChildren Box2D clip, direction 2/4). A 0 id is a no-op.
+ private void DrawVBar(UiRenderContext ctx, Func resolve,
+ uint tileId, float visibleH, bool fromBottom, bool isFill)
+ {
+ if (tileId == 0 || visibleH <= 0f) return;
+ var (tex, _, _) = resolve(tileId);
+ if (tex == 0) return;
+ float w = Width, h = Height;
+ if (visibleH > h) visibleH = h;
+ float frac = h > 0f ? visibleH / h : 0f;
+ // Bottom-up fill: bottom of the rect AND bottom of the sprite (v in [1-frac, 1]).
+ // Top-down / track: top of the rect AND top of the sprite (v in [0, frac]).
+ float y = isFill && fromBottom ? h - visibleH : 0f;
+ float v0 = isFill && fromBottom ? 1f - frac : 0f;
+ float v1 = isFill && fromBottom ? 1f : frac;
+ ctx.DrawSprite(tex, 0f, y, w, visibleH, 0f, v0, 1f, v1, System.Numerics.Vector4.One);
+ }
+
/// Draw a slice over local [,
/// pieceX+], with the texture repeating every
/// px (UV-repeat — the UI texture is GL_REPEAT-wrapped).
diff --git a/tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs b/tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs
new file mode 100644
index 00000000..6f130e24
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs
@@ -0,0 +1,35 @@
+using AcDream.App.UI;
+using Xunit;
+
+namespace AcDream.App.Tests.UI;
+
+public class UiMeterVerticalTests
+{
+ [Fact]
+ public void Vertical_fill_from_bottom_occupies_lower_fraction()
+ {
+ // 11x58 bar, 50% → fill rect is the bottom 29px.
+ var (x, y, w, h) = UiMeter.ComputeVFillRect(0.5f, 11f, 58f, fromBottom: true);
+ Assert.Equal(0f, x);
+ Assert.Equal(29f, y, 3); // h - h*p = 58 - 29
+ Assert.Equal(11f, w);
+ Assert.Equal(29f, h, 3);
+ }
+
+ [Fact]
+ public void Vertical_fill_from_top_starts_at_zero()
+ {
+ var (_, y, _, h) = UiMeter.ComputeVFillRect(0.25f, 11f, 58f, fromBottom: false);
+ Assert.Equal(0f, y);
+ Assert.Equal(14.5f, h, 3);
+ }
+
+ [Theory]
+ [InlineData(-1f, 0f)]
+ [InlineData(2f, 58f)]
+ public void Vertical_fill_clamps_fraction(float pct, float expectedH)
+ {
+ var (_, _, _, h) = UiMeter.ComputeVFillRect(pct, 11f, 58f, fromBottom: true);
+ Assert.Equal(expectedH, h, 3);
+ }
+}