feat(ui): port retail jump power bar

Import gmFloatyPowerBarUI LayoutDesc 0x21000072, teach the meter factory its stateful single-image shape, and project the movement-owned retail jump charge through a focused retained controller. Preserve authored resize constraints and state-managed visibility, with named-decomp pseudocode plus controller, movement, and production-DAT conformance coverage.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-13 10:22:03 +02:00
parent 27619b2328
commit db03b4bda8
18 changed files with 3045 additions and 10 deletions

View file

@ -311,7 +311,9 @@ public static class DatWidgetFactory
.OrderBy(c => c.ReadOrder)
.ToList();
if (containers.Count >= 2)
if (containers.Count >= 2
&& HasThreeSliceShape(containers[0])
&& HasThreeSliceShape(containers[1]))
{
// Vitals 3-slice shape: two Type-3 containers each holding 3 grandchild images
// (left-cap / center-tile / right-cap). Back is the lower ReadOrder; front is higher.
@ -325,7 +327,7 @@ public static class DatWidgetFactory
m.FrontTile = ft;
m.FrontRight = fr;
}
else if (containers.Count == 1)
else if (containers.Count == 1 && containers[0].StateMedia.ContainsKey(""))
{
// Single-image shape used by the toolbar selected-object meters
// (health 0x100001A1, mana 0x100001A2).
@ -354,15 +356,50 @@ public static class DatWidgetFactory
m.FrontTile = containers[0].StateMedia.TryGetValue("", out var fm) ? fm.File : 0u;
m.FrontRight = 0;
}
else if (containers.Any(HasStatefulFill))
{
// Stateful single-image shape used by gmPowerbarUI (LayoutDesc
// 0x21000072). The meter's DirectState is the empty track. One
// Type-3 child supplies named Jump/Melee/Missile/DDD state media;
// another carries the optional recklessness range as DirectState.
// gmPowerbarUI::PostInit @ 0x004DA4E0 hides that range, so it is not
// promoted into a meter slice here.
m.BackLeft = 0;
m.BackTile = info.StateMedia.TryGetValue("", out var track) ? track.File : 0u;
m.BackRight = 0;
m.FrontLeft = 0;
m.FrontTile = 0;
m.FrontRight = 0;
foreach (ElementInfo container in containers)
{
foreach (var (stateId, state) in container.States)
{
if (stateId == UiStateInfo.DirectStateId
|| !container.StateMedia.TryGetValue(state.Name, out var media))
continue;
m.ConfigureStateFill(stateId, media.File);
}
}
}
else
{
// Count == 0: no Type-3 containers at all — genuinely malformed meter dat.
Console.WriteLine($"[D.2b] meter 0x{info.Id:X8}: {containers.Count} Type-3 slice containers (expected 1 or 2) — bars may render as solid-color fallback.");
Console.WriteLine($"[D.2b] meter 0x{info.Id:X8}: {containers.Count} Type-3 containers but no recognized 3-slice, direct-fill, or stateful-fill shape — bar may render as solid-color fallback.");
}
return m;
}
private static bool HasThreeSliceShape(ElementInfo container)
=> container.Children.Count(c =>
c.StateMedia.TryGetValue("", out var media) && media.File != 0) >= 3;
private static bool HasStatefulFill(ElementInfo container)
=> container.States.Any(pair =>
pair.Key != UiStateInfo.DirectStateId
&& container.StateMedia.TryGetValue(pair.Value.Name, out var media)
&& media.File != 0);
/// <summary>
/// Returns the (left, tile, right) sprite ids for a 3-slice container,
/// extracting them from the container's image children that carry a DirectState

View file

@ -0,0 +1,91 @@
using AcDream.App.Input;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Projects <see cref="PlayerMovementController.JumpCharge"/> into the imported
/// retail <c>gmFloatyPowerBarUI</c>. The movement controller owns the clock; this
/// controller owns only DAT state, fill, and window visibility.
/// </summary>
/// <remarks>
/// Retail references: <c>gmPowerbarUI::RecvNotice_BeginPowerbar</c>
/// (0x004DA730), <c>RecvNotice_SetPowerbarLevel</c> (0x004DA550),
/// <c>RecvNotice_FinishPowerbar</c> (0x004DA580), and
/// <c>ClientCombatSystem::CommenceJump</c> (0x0056AF90).
/// </remarks>
public sealed class JumpPowerbarController : IRetainedPanelController
{
public const uint LayoutId = 0x21000072u;
public const uint MeterId = 0x10000034u;
public const uint JumpModeState = 0x10000042u;
private readonly UiMeter _meter;
private readonly Func<JumpChargeSnapshot> _snapshot;
private readonly Action<bool> _setWindowVisible;
private float _power;
private bool _isCharging;
private bool _hasSynced;
private bool _disposed;
private JumpPowerbarController(
UiMeter meter,
Func<JumpChargeSnapshot> snapshot,
Action<bool> setWindowVisible)
{
_meter = meter;
_snapshot = snapshot;
_setWindowVisible = setWindowVisible;
_meter.Fill = () => _power;
SyncVisibility(force: true);
}
public static JumpPowerbarController? Bind(
ImportedLayout layout,
Func<JumpChargeSnapshot> snapshot,
Action<bool> setWindowVisible)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentNullException.ThrowIfNull(setWindowVisible);
if (layout.FindElement(MeterId) is not UiMeter meter
|| !meter.TrySetRetailState(JumpModeState))
return null;
return new JumpPowerbarController(meter, snapshot, setWindowVisible);
}
/// <summary>Applies the latest movement-owned charge snapshot.</summary>
public void Tick() => SyncVisibility(force: false);
/// <summary>
/// Re-applies the state-driven visibility after the window has been mounted.
/// This covers a jump key already held during retained-UI construction.
/// </summary>
public void SyncVisibility() => SyncVisibility(force: true);
private void SyncVisibility(bool force)
{
if (_disposed) return;
JumpChargeSnapshot state = _snapshot();
_power = state.IsCharging ? Math.Clamp(state.Power, 0f, 1f) : 0f;
if (force || !_hasSynced || state.IsCharging != _isCharging)
{
_isCharging = state.IsCharging;
_hasSynced = true;
_setWindowVisible(_isCharging);
}
}
public void OnShown() => SyncVisibility(force: false);
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_power = 0f;
_meter.Fill = () => null;
}
}

View file

@ -1,6 +1,7 @@
using System.Collections.Generic;
using AcDream.App.Plugins;
using AcDream.App.Combat;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.UI.Layout;
using AcDream.App.UI.Testing;
@ -42,6 +43,8 @@ public sealed record CombatRuntimeBindings(
Func<GameplaySettings> Gameplay,
Action<GameplaySettings> SetGameplay);
public sealed record JumpPowerbarRuntimeBindings(Func<JumpChargeSnapshot> Snapshot);
public sealed record ToolbarRuntimeBindings(
ClientObjectTable Objects,
Func<IReadOnlyList<ShortcutEntry>> Shortcuts,
@ -106,6 +109,7 @@ public sealed record RetailUiRuntimeBindings(
ChatRuntimeBindings Chat,
RadarRuntimeBindings Radar,
CombatRuntimeBindings Combat,
JumpPowerbarRuntimeBindings JumpPowerbar,
ToolbarRuntimeBindings Toolbar,
CharacterRuntimeBindings Character,
InventoryRuntimeBindings Inventory,
@ -136,6 +140,7 @@ public sealed class RetailUiRuntime : IDisposable
MountChat();
MountToolbar();
MountCombat();
MountJumpPowerbar();
MountCharacter();
MountPlugins();
MountInventory();
@ -150,7 +155,7 @@ public sealed class RetailUiRuntime : IDisposable
persistence.Store,
persistence.CharacterKey,
persistence.ScreenSize,
stateManagedVisibilityWindows: [WindowNames.Combat]);
stateManagedVisibilityWindows: [WindowNames.Combat, WindowNames.JumpPowerbar]);
}
if (bindings.Probe.Enabled)
@ -173,6 +178,7 @@ public sealed class RetailUiRuntime : IDisposable
public ToolbarController? ToolbarController { get; private set; }
public ToolbarInputController? ToolbarInputController { get; private set; }
public CombatUiController? CombatUiController { get; private set; }
public JumpPowerbarController? JumpPowerbarController { get; private set; }
public SelectedObjectController? SelectedObjectController { get; private set; }
public UiViewport? PaperdollViewportWidget { get; private set; }
public UiNineSlicePanel? InventoryFrame { get; private set; }
@ -193,6 +199,7 @@ public sealed class RetailUiRuntime : IDisposable
public void Tick(double deltaSeconds)
{
JumpPowerbarController?.Tick();
SelectedObjectController?.Tick(deltaSeconds);
Host.Tick(deltaSeconds);
_automation?.Tick(deltaSeconds);
@ -520,6 +527,65 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[M2] retail combat bar from gmCombatUI LayoutDesc 0x21000073.");
}
private void MountJumpPowerbar()
{
ElementInfo? info;
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
info = LayoutImporter.ImportInfos(
_bindings.Assets.Dats, JumpPowerbarController.LayoutId);
layout = info is null ? null : LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (layout is null)
{
Console.WriteLine("[D.6] jump powerbar: LayoutDesc 0x21000072 not found.");
return;
}
JumpPowerbarController? controller = Layout.JumpPowerbarController.Bind(
layout,
_bindings.JumpPowerbar.Snapshot,
visible =>
{
if (visible) Host.ShowWindow(WindowNames.JumpPowerbar);
else Host.HideWindow(WindowNames.JumpPowerbar);
});
if (controller is null)
{
Console.WriteLine("[D.6] jump powerbar: required JumpMode meter missing in LayoutDesc 0x21000072.");
return;
}
JumpPowerbarController = controller;
UiElement root = layout.Root;
RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.JumpPowerbar,
Chrome = RetailWindowChrome.Imported,
Left = Math.Max(0f, (Host.Root.Width - root.Width) * 0.5f),
Top = Math.Max(0f, Host.Root.Height - root.Height - 105f),
Visible = false,
DatConstraintSource = info,
Resizable = true,
ResizeX = true,
ResizeY = true,
ConstrainDragToParent = true,
ContentClickThrough = false,
Controller = controller,
});
controller.SyncVisibility();
Console.WriteLine("[D.6] retail jump bar from gmFloatyPowerBarUI LayoutDesc 0x21000072.");
}
private (uint[] Peace, uint[] War, uint[]? Empty) LoadToolbarDigits()
{
uint[]? peace = null, war = null, empty = null;

View file

@ -14,8 +14,10 @@ namespace AcDream.App.UI;
/// retail's vitals are bars exactly like this, just sprited.
/// </para>
/// </summary>
public sealed class UiMeter : UiElement
public sealed class UiMeter : UiElement, IUiDatStateful
{
private readonly Dictionary<uint, uint> _stateFillSprites = new();
/// <summary>Dat element id, set by the layout importer so duplicated page copies can be scoped.</summary>
public uint ElementId { get; set; }
@ -57,6 +59,32 @@ public sealed class UiMeter : UiElement
/// <summary>Coloured-fill right-cap RenderSurface id.</summary>
public uint FrontRight { get; set; }
/// <summary>The active numeric DAT state for a stateful fill meter.</summary>
public uint ActiveRetailStateId { get; private set; }
/// <summary>
/// Registers a fill sprite carried by a child state of the imported meter.
/// Used by the powerbar shape in LayoutDesc 0x21000072, whose track is on
/// the meter and whose Jump/Melee/Missile/DDD fills are states of one child.
/// </summary>
internal void ConfigureStateFill(uint stateId, uint spriteId)
{
if (stateId != 0 && spriteId != 0)
_stateFillSprites[stateId] = spriteId;
}
public bool TrySetRetailState(uint stateId)
{
if (!_stateFillSprites.TryGetValue(stateId, out uint spriteId))
return false;
ActiveRetailStateId = stateId;
FrontLeft = 0;
FrontTile = spriteId;
FrontRight = 0;
return true;
}
/// <summary>Vertical orientation (retail <c>m_eDirection</c> 2/4). Default false =
/// horizontal (direction 1). The burden bar is the only vertical meter today.</summary>
public bool Vertical { get; set; }

View file

@ -11,4 +11,5 @@ public static class WindowNames
public const string Chat = "chat";
public const string Radar = "radar";
public const string Combat = "combat";
public const string JumpPowerbar = "jump-powerbar";
}