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

@ -46,6 +46,36 @@ Copy this block when adding a new issue:
---
## #209 — Retail jump power bar missing
**Status:** IN-PROGRESS — implementation complete 2026-07-13, pending live visual gate
**Severity:** MEDIUM
**Component:** retained UI / movement
**Description:** Holding the jump key charged the movement system correctly,
but the separate retail floating power bar did not appear or show jump power.
**Root cause:** The production retained runtime had not mounted
`gmFloatyPowerBarUI` LayoutDesc `0x21000072`. The generic meter importer also
mistook its stateful fill child plus hidden Recklessness child for a vitals-style
three-slice meter, leaving all slice ids empty.
**Resolution:** The importer now recognizes the stateful single-image meter
shape: the authored direct image is the empty track and the selected numeric
child state supplies the fill. `JumpPowerbarController` selects JumpMode, reads
the movement controller's existing retail-timed charge snapshot, shows the
state-managed window on charge begin, fills left-to-right, and resets/hides it
on release. Geometry and resize limits come from the authored DAT layout.
**Research:** `docs/research/2026-07-13-retail-jump-powerbar-pseudocode.md`
**Acceptance:** In the connected Release client, hold Jump: the 610×25 retail
bar appears and fills smoothly from its left edge to its right edge in about
one second. Keep holding at full, then release: the bar resets and disappears as
the jump starts. Repeat after moving/resizing the bar and after relogging.
---
## #208 — Combat bar appears after logging in peacefully
**Status:** IN-PROGRESS — fix shipped 2026-07-12, pending live gate

View file

@ -506,7 +506,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.
- **✓ D.5.3 — Toolbar selected-object display (issue #141) SHIPPED.** Exact health/player/pet/Free-PK policy, selected-item mana (`0x100001A2`), stack entry (`0x100001A3`), stack slider (`0x100001A4`), formatted count/name, shared split quantity, click-to-use, peace/war, health/name, and paperdoll self/upper-item selection all passed live by 2026-07-11. Exact raw `ShortCutData` preservation is already shipped in Wave 4.2; the eight `favorite_spells_` lists belong to the separate spellbook/spell-bar phase.
- **☐ D.5.5+ — Core panels.** Inventory (`gmInventoryUI`/`gmBackpackUI`), equipment/paperdoll (`gmPaperDollUI`/`gm3DItemsUI` + the `UiViewport` 3D doll), vendor, trade, spellbook. Research drop done (`docs/research/2026-06-16-*`). Depends on **D.5.4** (data model) + the item-slot/list/icon spine (D.5.1/D.5.2) + the **window manager** (Plan 2: open/close/z-order/persist + faithful grip/dragbar drag-resize) + the drag-drop spine wired (`UiRoot` has the chain; the per-cell accept/drop hooks are still stubs in `UiField`). Also deferred from D.5.1: drag/reorder + the `AddShortcut`/`RemoveShortcut` mutate wire.
- **D.6 — HUD.** **Radar/compass IMPLEMENTED 2026-07-10; live-world visual gate pending.** It is the retained retail `gmRadarUI` (`LayoutDesc 0x21000074`): a 120×140 circular radar (`0x06004CC1`) with N/E/S/W token sprites orbiting the face, a fixed bright-green player marker, and the coordinate footer (`0x06004CC0`). Projection is `radarPixelRadius / radarRange` with 75 m outdoor / 25 m indoor ranges — there is no 1.18 factor or scrolling compass strip. It lives in `AcDream.App/UI` beside the other imported retail panels. Remaining D.6: target name plate, damage floaters, and other world-space HUD elements through the raw `TextRenderer` path. See slice 06 §A.2, `docs/research/2026-07-10-retail-radar-pseudocode.md`, and the named `gmRadarUI` decomp.
- **D.6 — HUD.** **Radar/compass IMPLEMENTED 2026-07-10; jump power bar IMPLEMENTED 2026-07-13; live-world visual gates pending.** The radar is retained retail `gmRadarUI` (`LayoutDesc 0x21000074`): a 120×140 circular face (`0x06004CC1`) with orbiting N/E/S/W tokens, a fixed bright-green player marker, and coordinate footer (`0x06004CC0`). Projection is `radarPixelRadius / radarRange` with 75 m outdoor / 25 m indoor ranges — no 1.18 factor or scrolling strip. The separate `gmFloatyPowerBarUI` (`LayoutDesc 0x21000072`) now appears only while jump is pending and clips the authored JumpMode fill left-to-right from the movement controller's shared 01 charge. Both live in `AcDream.App/UI` behind focused controllers; `GameWindow` supplies state delegates only. Remaining D.6: target name plate, damage floaters, and other world-space HUD elements through the raw `TextRenderer` path. See slice 06 §A.2, `docs/research/2026-07-10-retail-radar-pseudocode.md`, `docs/research/2026-07-13-retail-jump-powerbar-pseudocode.md`, and the named `gmRadarUI`/`gmPowerbarUI` decomp.
- **✓ SHIPPED — D.7 — Cursor manager (user-confirmed 2026-07-11).** All reachable `ClientUISystem::UpdateCursorState @ 0x00564630` branches resolve through production DAT EnumIDMap table 6, with event-ordered global-default versus captured/hovered `MediaDescCursor` layering from `UIElementManager::CheckCursor @ 0x0045ABF0`. OS fallback remains only AP-72 and emits a visible diagnostic. The live gate covered world/object, combat, targeted-use, text, move/resize, and drag contexts; combat-indicator mouse clicks and the quick key share the same toggle command.
- ~~**D.8 — Sound.**~~ **Superseded — shipped as Phase E.2** (`SoundTable`/`Sound` dat decode, OpenAL 16-voice engine, per-entity 3D positional audio via `AudioHookSink`). Entry kept here for history; see the shipped table.

View file

@ -566,11 +566,13 @@ Open the inventory panel — retail-skinned with the right font, icons, and
- **C.3** — Palette range tuning (skin/hair/eye colors match retail).
- **C.4** — Double-sided translucent polys.
- **D.2b** — Custom retail-look UI backend. **In progress:** vitals + chat + toolbar + inventory (window manager, B-Grid, B-Controller, B-Wire, drag-drop, container-switching) + paperdoll (equip slots, 3-D doll UiViewport, Slots toggle) + **Character window** (Attributes tab, visually confirmed 2026-06-26) + **UI Studio** (standalone panel previewer + 26-window retail dump + interactive click-routing + headless screenshot) + **importer dat-fidelity** (Fix A/B/C: justification, FontColor, per-element FontDid resolver [studio-only; #157 tracks live-game path]) are shipped. Current campaign: shared retained-widget/window architecture, then selected-object mana/stack completion. **Retail correction:** the main toolbar is object-only; the eight favorite-spell lists drive separate spell bars. Execution plan: `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`.
- **D.3D.7** — AcFont + dat sprites + core panels reskinned + HUD orbs +
cursor manager. **D.6 radar/compass shipped 2026-07-10; D.7 complete retail
- **D.3D.7** — AcFont + dat sprites + core panels reskinned + HUD +
cursor manager. **D.6 radar/compass shipped 2026-07-10 and the separate retail
jump power bar was implemented 2026-07-13; D.7 complete retail
cursor catalog/precedence shipped and user-confirmed 2026-07-11.** The radar uses the retained
UI architecture using the retail DAT layout and recovered runtime behavior;
live-game visual confirmation remains the user gate.
the jump bar uses the same retained composition with movement-owned charge timing.
Live-game visual confirmation remains the user gate.
- **L.1f** — NPC/monster + item-use animation coverage.
**Freeze on landing:**

View file

@ -0,0 +1,135 @@
# Retail jump power bar — pseudocode and port map
Date: 2026-07-13
Binary: Sept. 2013 EoR `acclient.exe` v11.4186
Scope: the floating `gmPowerbarUI` presentation used while the player charges a jump
## Named retail sources
- `gmPowerbarUI::PostInit @ 0x004DA4E0`
- `gmPowerbarUI::RecvNotice_SetPowerbarLevel @ 0x004DA550`
- `gmPowerbarUI::RecvNotice_FinishPowerbar @ 0x004DA580`
- `gmPowerbarUI::RecvNotice_BeginPowerbar @ 0x004DA730`
- `gmFloatyPowerBarUI::PostInit @ 0x004D0F00`
- `gmFloatyPowerBarUI::UpdateFromPlayerModule @ 0x004D1200`
- `gmFloatyPowerBarUI::ResizeTo @ 0x004D1460`
- `gmFloatyPowerBarUI::MoveTo @ 0x004D15D0`
- `gmFloatyPowerBarUI::SetVisible @ 0x004D17D0`
- `ClientCombatSystem::FinishJump @ 0x0056A9B0`
- `ClientCombatSystem::StartPowerBarBuild @ 0x0056ADB0`
- `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`
- `ClientCombatSystem::CommenceJump @ 0x0056AF90`
- `ClientCombatSystem::DoJump @ 0x0056B110`
The symbols and addresses above are present in
`docs/research/named-retail/symbols.json`; the bodies are in
`docs/research/named-retail/acclient_2013_pseudo_c.txt`.
## Authored DAT layout
Portal DAT `LayoutDesc 0x21000072` is a `gmFloatyPowerBarUI` window:
| Element | ID | Geometry | Media/state |
|---|---:|---:|---|
| Root | `0x10000612` | 610×25 px | custom type `0x10000053` |
| Meter | `0x10000034` | x=5, y=5, 600×15 px | empty track `0x06004D0B` |
| Fill field | `0x00000002` | 600×15 px | JumpMode `0x10000042``0x06001354`; MeleeMode `0x10000043` and MissileMode `0x10000044``0x06001200`; DDDMode `0x10000045``0x0600195B` |
| Recklessness field | `0x100005EE` | x=60, 480×15 px | `0x0600715E`; hidden by `PostInit` |
The meter's fill is clipped horizontally. A level of 0.0 shows no fill; 0.5
shows the left 300 px; 1.0 shows the full 600 px. It does not stretch from the
center or from the right.
## Retail lifecycle pseudocode
```text
CommenceJump():
if jump_pending:
return
error = motion_interpreter.charge_jump()
if error != success:
report the matching jump error
return
cancel repeat attack if active
latest_powerbar_level = 0
jump_pending = true
powerbar_mode = JUMP
notify BeginPowerbar(JUMP)
StartPowerBarBuild()
StartPowerBarBuild():
build_in_progress = true
build_start_time = current_time
notify SetPowerbarLevel(powerbar_mode, 0)
latest_powerbar_level = 0
GetPowerBarLevel():
if not build_in_progress:
return 0
duration = 0.8 seconds in dual-wield style, otherwise 1.0 second
return clamp((current_time - build_start_time) / duration, 0, 1)
gmPowerbarUI.BeginPowerbar(JUMP):
set root state to JumpMode (0x10000042)
current_mode = JUMP
set meter 0x10000034 level to 0
show window
gmPowerbarUI.SetPowerbarLevel(mode, level):
if mode == current_mode:
set meter 0x10000034 level to level
DoJump(on release):
extent = max(GetPowerBarLevel(), MIN_JUMP_EXTENT)
FinishJump()
motion_interpreter.jump(extent)
send JumpPack(extent, velocity, position/update sequences)
FinishJump():
if jump_pending:
stop build and clear its time
notify FinishPowerbar(JUMP)
powerbar_mode = UNDEFINED
clear standing_longjump
jump_pending = false
gmPowerbarUI.FinishPowerbar(JUMP):
if JUMP == current_mode:
current_mode = UNDEFINED
set meter level to 0
hide window
```
`gmPowerbarUI::PostInit` also hides the recklessness field. The floaty subclass
restores and persists its position, size, lock chrome, and visibility through
the normal retail window preferences.
## Reference cross-checks
- ACE `ACE.Server/Network/Structure/JumpPack.cs` documents `Extent` as jump
power 01. `ACE.Server/WorldObjects/Player.cs::HandleActionJump` clamps the
received extent to that same interval before applying jump stamina/height.
- holtburger `crates/holtburger-core/src/client/commands.rs` carries the chosen
extent unchanged into the `JumpData` action. Its vendored ACViewer movement
implementation likewise stores jump extent as the power input to the jump
calculation.
These references corroborate the data range and its handoff. The named retail
decomp remains authoritative for charge timing, notices, visibility, and UI
state selection.
## acdream port map
- `PlayerMovementController` already owns the exact shared retail charge clock
and release transition. Expose it as a read-only `(IsCharging, Power)`
snapshot; do not add a UI timer.
- Teach `UiMeter`/`DatWidgetFactory` the DAT's stateful single-image meter shape:
meter direct media is the track, child state media is the selected fill.
- `JumpPowerbarController` selects JumpMode, binds fill to the movement snapshot,
and performs the begin/show and finish/reset/hide transitions.
- `RetailUiRuntime` imports and mounts `0x21000072` as a state-managed retained
window. `GameWindow` only supplies the movement snapshot delegate.
This port introduces no intentional retail divergence.

View file

@ -18,6 +18,13 @@ public readonly record struct MovementInput(
float MouseDeltaX = 0f,
bool Jump = false);
/// <summary>
/// Read-only presentation snapshot of retail's pending jump build. The movement
/// controller remains the sole owner of charge timing; retained UI only projects
/// this state into <c>gmPowerbarUI</c>.
/// </summary>
public readonly record struct JumpChargeSnapshot(bool IsCharging, float Power);
/// <summary>
/// Result of a single frame's movement update.
///
@ -165,6 +172,13 @@ public sealed class PlayerMovementController
// Jump charge state.
private bool _jumpCharging;
private float _jumpExtent;
/// <summary>
/// Current retail jump-powerbar state. Power is always zero when no jump is
/// pending and otherwise lies in [0,1].
/// </summary>
public JumpChargeSnapshot JumpCharge
=> new(_jumpCharging, _jumpCharging ? _jumpExtent : 0f);
// Matching v11.4186 x86 resolves GetPowerBarLevel's collapsed x87
// operands: ATTACK_POWERUP_TIME=1.0 s, DUAL_WIELD_POWERUP_TIME=0.8 s.
// Jump uses the same shared powerbar function, so its normal fill rate is

View file

@ -2142,6 +2142,8 @@ public sealed class GameWindow : IDisposable
_combatAttackController,
() => _persistedGameplay,
SetRetailCombatGameplay),
JumpPowerbar: new AcDream.App.UI.JumpPowerbarRuntimeBindings(
() => _playerController?.JumpCharge ?? default),
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
Objects,
() => Shortcuts,

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";
}

View file

@ -313,6 +313,37 @@ public class DatWidgetFactoryTests
Assert.Equal(0u, m.FrontRight);
}
[Fact]
public void BuildMeter_StatefulSingleImageShape_MapsNumericFillStatesAndHidesDirectRange()
{
const uint TrackFile = 0x06004D0Bu;
const uint JumpState = 0x10000042u;
const uint JumpFile = 0x06001354u;
const uint RangeFile = 0x0600715Eu;
var meter = new ElementInfo { Type = 7, Id = 0x10000034u, Width = 600, Height = 15 };
meter.StateMedia[""] = (TrackFile, 1);
var fills = new ElementInfo { Type = 3, Id = 2u, ReadOrder = 2 };
fills.States[JumpState] = new UiStateInfo { Id = JumpState, Name = "JumpMode" };
fills.StateMedia["JumpMode"] = (JumpFile, 1);
meter.Children.Add(fills);
var range = new ElementInfo { Type = 3, Id = 0x100005EEu, ReadOrder = 1 };
range.StateMedia[""] = (RangeFile, 1);
meter.Children.Add(range);
var result = Assert.IsType<UiMeter>(DatWidgetFactory.Create(meter, NoTex, null));
Assert.Equal(TrackFile, result.BackTile);
Assert.Equal(0u, result.FrontTile);
Assert.True(result.TrySetRetailState(JumpState));
Assert.Equal(JumpState, result.ActiveRetailStateId);
Assert.Equal(JumpFile, result.FrontTile);
Assert.NotEqual(RangeFile, result.BackTile);
Assert.NotEqual(RangeFile, result.FrontTile);
}
// ── Test 6: Meter slice extraction (the important one) ───────────────────
/// <summary>

View file

@ -95,6 +95,12 @@ public static class FixtureLoader
public static ElementInfo LoadCombatInfos()
=> LoadInfos("combat_21000073.json");
public static ImportedLayout LoadPowerbar()
=> LayoutImporter.Build(LoadPowerbarInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadPowerbarInfos()
=> LoadInfos("powerbar_21000072.json");
// ── Shared loader ────────────────────────────────────────────────────────
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)

View file

@ -0,0 +1,73 @@
using AcDream.App.Input;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class JumpPowerbarControllerTests
{
private const uint JumpFill = 0x06001354u;
[Fact]
public void MovementCharge_ShowsUpdatesAndHidesAuthoredJumpMeter()
{
JumpChargeSnapshot state = default;
ImportedLayout layout = BuildLayout();
var visibility = new List<bool>();
using var controller = JumpPowerbarController.Bind(
layout, () => state, visibility.Add)!;
var meter = Assert.IsType<UiMeter>(layout.FindElement(JumpPowerbarController.MeterId));
Assert.Equal([false], visibility);
Assert.Equal(0f, meter.Fill());
Assert.Equal(JumpPowerbarController.JumpModeState, meter.ActiveRetailStateId);
Assert.Equal(JumpFill, meter.FrontTile);
state = new JumpChargeSnapshot(true, 0.35f);
controller.Tick();
Assert.Equal([false, true], visibility);
Assert.Equal(0.35f, meter.Fill());
state = new JumpChargeSnapshot(true, 1.4f);
controller.Tick();
Assert.Equal([false, true], visibility);
Assert.Equal(1f, meter.Fill());
state = default;
controller.Tick();
Assert.Equal([false, true, false], visibility);
Assert.Equal(0f, meter.Fill());
}
[Fact]
public void Bind_RejectsLayoutWithoutJumpModeMeter()
{
var root = new UiPanel();
var meter = new UiMeter { ElementId = JumpPowerbarController.MeterId };
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
{
[JumpPowerbarController.MeterId] = meter,
});
Assert.Null(JumpPowerbarController.Bind(layout, () => default, _ => { }));
}
private static ImportedLayout BuildLayout()
{
var root = new UiPanel { Width = 610, Height = 25 };
var meter = new UiMeter
{
ElementId = JumpPowerbarController.MeterId,
Left = 5,
Top = 5,
Width = 600,
Height = 15,
};
meter.ConfigureStateFill(JumpPowerbarController.JumpModeState, JumpFill);
root.AddChild(meter);
return new ImportedLayout(root, new Dictionary<uint, UiElement>
{
[JumpPowerbarController.MeterId] = meter,
});
}
}

View file

@ -0,0 +1,33 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class JumpPowerbarLayoutConformanceTests
{
[Fact]
public void RetailFixture_UsesExactFloatyGeometryTrackAndJumpFill()
{
ElementInfo info = FixtureLoader.LoadPowerbarInfos();
ImportedLayout layout = LayoutImporter.Build(info, _ => (0u, 0, 0), null);
var meter = Assert.IsType<UiMeter>(
layout.FindElement(JumpPowerbarController.MeterId));
Assert.Equal((610f, 25f), (layout.Root.Width, layout.Root.Height));
Assert.Equal((5f, 5f, 600f, 15f),
(meter.Left, meter.Top, meter.Width, meter.Height));
Assert.Equal(0x06004D0Bu, meter.BackTile);
Assert.True(meter.TrySetRetailState(JumpPowerbarController.JumpModeState));
Assert.Equal(0x06001354u, meter.FrontTile);
Assert.Equal((0f, 0f, 300f, 15f),
UiMeter.ComputeFillRect(0.5f, meter.Width, meter.Height));
Assert.True(info.TryGetEffectiveInteger(0x3Fu, out int minWidth));
Assert.True(info.TryGetEffectiveInteger(0x3Du, out int maxWidth));
Assert.True(info.TryGetEffectiveInteger(0x3Eu, out int minHeight));
Assert.True(info.TryGetEffectiveInteger(0x3Cu, out int maxHeight));
Assert.Equal((50, 810, 25, 25),
(minWidth, maxWidth, minHeight, maxHeight));
}
}

View file

@ -21,6 +21,7 @@ public sealed class RetailLayoutFixtureGenerator
(0x21000024u, "paperdoll_21000024.json"),
(0x2100002Eu, "character_2100002E.json"),
(0x2100006Cu, "vitals_2100006C.json"),
(0x21000072u, "powerbar_21000072.json"),
(0x21000073u, "combat_21000073.json"),
(0x21000074u, "radar_21000074.json"),
};

File diff suppressed because it is too large Load diff

View file

@ -293,6 +293,24 @@ public class PlayerMovementControllerTests
Assert.True(controller.VerticalVelocity > 0f);
}
[Fact]
public void JumpChargeSnapshot_TracksHeldChargeAndResetsOnRelease()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
Assert.Equal(default, controller.JumpCharge);
controller.Update(0.25f, new MovementInput(Jump: true));
Assert.True(controller.JumpCharge.IsCharging);
Assert.Equal(0.25f, controller.JumpCharge.Power, precision: 3);
controller.Update(0.016f, new MovementInput(Jump: false));
Assert.False(controller.JumpCharge.IsCharging);
Assert.Equal(0f, controller.JumpCharge.Power);
}
[Fact]
public void Update_AirborneFrames_ZRiseThenFalls()
{