fix: powerbar mode captions - jump 'Height' + combat 'Power'/'Accuracy'

Retail authors ONE caption element per bar with per-mode state strings,
switched by a PassToChildren state cascade (gmPowerbarUI::
RecvNotice_BeginPowerbar @0x004DA730 sets 0x10000042 Jump / 0x10000043
Melee / 0x10000044 Missile / 0x10000045 DDD; installed-DAT probe
confirmed every string + PassToChildren flag).

- Jump bar (user gate): the floaty powerbar's caption child (0x10000035:
  JumpMode 'Height', authored HJustify=Center over the bar) was dropped
  by UiMeter's child absorption. The stateful-fill meter build now
  absorbs it into per-state labels; TrySetRetailState latches the
  caption and OnDraw shows it when no live Label provider is bound.
  JumpPowerbarController's existing JumpMode flip now surfaces 'Height'
  with zero controller changes. The mount gained the string resolver the
  Build call never passed.
- Combat bar (user gate): label 0x10000052 authors 'MeleeCombat' ->
  'Power' and 'MissileCombat' -> 'Accuracy'; the controller latched the
  MELEE string once at bind. CombatUiLabels now resolves both authored
  strings and OnCombatModeChanged sets the mode's string - switching
  live when swapping melee <-> missile weapons in combat. Also fixed
  the mode-state flip target: the states live on the BASIC PANEL
  (0x1000005C, PassToChildren), not the layout root (Hide/ShowDetail
  only) - the old _root flip was a silent no-op.

New env-gated ACDREAM_PROBE_POWERBAR layout probe (kept, house
pattern). App suite 4,987/3 skips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-14 08:14:14 +02:00
parent 4943484eb9
commit adf8335675
7 changed files with 233 additions and 14 deletions

View file

@ -65,6 +65,8 @@ public sealed class CombatUiController : IRetainedPanelController
private readonly CombatState _combat;
private readonly RuntimeCombatAttackState _attacks;
private readonly Bindings _bindings;
private readonly CombatUiLabels _labels;
private readonly UiText? _powerLabel;
private readonly Action<bool> _setWindowVisible;
private bool _disposed;
@ -98,6 +100,7 @@ public sealed class CombatUiController : IRetainedPanelController
_combat = combat;
_attacks = attacks;
_bindings = bindings;
_labels = labels;
_setWindowVisible = setWindowVisible;
// Retail layout 0x21000073 contains two sibling pages: gmCombatUI's
@ -120,9 +123,9 @@ public sealed class CombatUiController : IRetainedPanelController
_autoTarget.Label = labels.AutoTarget;
_keepInView.Label = labels.KeepInView;
UiText? speedLabel = layout.FindElement(SpeedLabelId) as UiText;
UiText? powerLabel = layout.FindElement(PowerLabelId) as UiText;
_powerLabel = layout.FindElement(PowerLabelId) as UiText;
SetStaticText(speedLabel, labels.Speed, rightAligned: false);
SetStaticText(powerLabel, labels.Power, rightAligned: true);
SetStaticText(_powerLabel, labels.Power, rightAligned: true);
// MUST-FIX 3 (OP4 review-fix round, 2026-08-11, blast M2): writes
// go through the SAME server-bit seam the Character tab's rows for
@ -187,13 +190,25 @@ public sealed class CombatUiController : IRetainedPanelController
bool visible = mode is CombatMode.Melee or CombatMode.Missile or CombatMode.Magic;
_basicPanel.Visible = mode is CombatMode.Melee or CombatMode.Missile;
_spellcastingPanel.Visible = mode == CombatMode.Magic;
if (_root is IUiDatStateful stateful)
// 2026-08-14 gate: the mode states live on the BASIC PANEL
// (0x1000005C authors 'MeleeCombat' 0x10000003 / 'MissileCombat'
// 0x10000004, PassToChildren — installed-DAT probe), NOT the layout
// root (which authors only Hide/ShowDetail) — the old _root target
// was a silent no-op.
if (_basicPanel is IUiDatStateful stateful)
{
if (mode == CombatMode.Melee)
stateful.TrySetRetailState(MeleeState);
else if (mode == CombatMode.Missile)
stateful.TrySetRetailState(MissileState);
}
// Retail's cascade swaps the power label's authored per-state string
// ('Power' ↔ 'Accuracy'); the retained label keeps its explicit
// controller-set line, so set the mode's authored string here.
SetStaticText(
_powerLabel,
mode == CombatMode.Missile ? _labels.Accuracy : _labels.Power,
rightAligned: true);
_setWindowVisible(visible);
SyncControls();
}
@ -253,10 +268,16 @@ public sealed class CombatUiController : IRetainedPanelController
}
}
/// <summary>Localized labels assigned by retail <c>gmCombatUI::PostInit</c>.</summary>
/// <summary>Localized labels assigned by retail <c>gmCombatUI::PostInit</c>.
/// <see cref="Power"/>/<see cref="Accuracy"/> are the power label's OWN
/// authored per-state strings (element 0x10000052: state 0x10000003
/// 'MeleeCombat' → 'Power', 0x10000004 'MissileCombat' → 'Accuracy' —
/// installed-DAT probe 2026-08-14); retail switches them through the basic
/// panel's PassToChildren state cascade.</summary>
public sealed record CombatUiLabels(
string Speed,
string Power,
string Accuracy,
string RepeatAttacks,
string AutoTarget,
string KeepInView,
@ -274,6 +295,7 @@ public sealed record CombatUiLabels(
return new CombatUiLabels(
ElementString(CombatUiController.SpeedLabelId, UiStateInfo.DirectStateId, "Speed"),
ElementString(CombatUiController.PowerLabelId, CombatUiController.MeleeState, "Power"),
ElementString(CombatUiController.PowerLabelId, CombatUiController.MissileState, "Accuracy"),
RuntimeString("ID_CombatPanelOption_AutoRepeatAttack", "Repeat Attacks"),
RuntimeString("ID_CombatPanelOption_AutoTarget", "Auto Target"),
RuntimeString("ID_CombatPanelOption_ViewCombatTarget", "Keep in View"),

View file

@ -105,7 +105,7 @@ public static class DatWidgetFactory
// propagation) because nothing ever activates it.
5 => new UiTemplateListBox(info, resolve, info.TemplateList, info.ScrollbarElementId),
6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
7 => BuildMeter(info, resolve, elementFont, stringResolve), // UIElement_Meter
// UIElement_Panel (Type 8) — retail's tab-strip host (dat property 0x2E;
// research doc §1.3/§10.1). OP2 rework (docs/research/2026-08-11-op2-
// review-mechanism.md MUST-FIX 5): Type 8 is UIElement_Panel, NOT a class
@ -443,7 +443,8 @@ public static class DatWidgetFactory
/// </para>
/// </summary>
private static UiMeter BuildMeter(ElementInfo info,
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont,
Func<UiStringInfoValue, string?>? stringResolve = null)
{
var m = new UiMeter
{
@ -534,6 +535,26 @@ public static class DatWidgetFactory
m.ConfigureStateFill(stateId, media.File);
}
}
// 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.
foreach (ElementInfo textChild in info.Children.Where(static c => c.Type == 12))
{
foreach (var (stateId, state) in textChild.States)
{
if (stateId == UiStateInfo.DirectStateId
|| !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);
}
}
}
else
{

View file

@ -2674,11 +2674,15 @@ public sealed class RetailUiRuntime : IDisposable
{
info = LayoutImporter.ImportInfos(
_bindings.Assets.Dats, JumpPowerbarController.LayoutId);
// The string resolver feeds the meter's absorbed per-mode caption
// (0x10000035: JumpMode 'Height' — 2026-08-14 gate).
var powerbarStrings = new DatStringResolver(_bindings.Assets.Dats);
layout = info is null ? null : LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
_bindings.Assets.ResolveFont,
powerbarStrings.Resolve);
}
if (layout is null)
{

View file

@ -17,6 +17,8 @@ namespace AcDream.App.UI;
public sealed class UiMeter : UiElement, IUiDatStateful
{
private readonly Dictionary<uint, uint> _stateFillSprites = new();
private readonly Dictionary<uint, string> _stateLabels = new();
private string? _activeStateLabel;
/// <summary>Dat element id, set by the layout importer so duplicated page copies can be scoped.</summary>
public uint ElementId { get; set; }
@ -72,6 +74,10 @@ public sealed class UiMeter : UiElement, IUiDatStateful
/// <summary>The active numeric DAT state for a stateful fill meter.</summary>
public uint ActiveRetailStateId { get; private set; }
/// <summary>The caption latched by the active retail state (null when the
/// state authors none). Exposed for tests.</summary>
internal string? ActiveStateLabel => _activeStateLabel;
/// <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
@ -83,15 +89,36 @@ public sealed class UiMeter : UiElement, IUiDatStateful
_stateFillSprites[stateId] = spriteId;
}
/// <summary>
/// Registers a caption carried by a state of the meter's absorbed Type-12
/// child (gmPowerbarUI's 0x10000035: JumpMode 'Height' / MeleeMode 'Power'
/// / MissileMode 'Accuracy'). Retail shows it via the meter's
/// PassToChildren state cascade; the absorbed equivalent latches it in
/// <see cref="TrySetRetailState"/> and draws it when no live
/// <see cref="Label"/> provider supplies text. The child authors
/// HJustify=Center — the same centered draw the label overlay uses.
/// </summary>
internal void ConfigureStateLabel(uint stateId, string text)
{
if (stateId != 0 && !string.IsNullOrEmpty(text))
_stateLabels[stateId] = text;
}
public bool TrySetRetailState(uint stateId)
{
if (!_stateFillSprites.TryGetValue(stateId, out uint spriteId))
bool hasFill = _stateFillSprites.TryGetValue(stateId, out uint spriteId);
bool hasLabel = _stateLabels.TryGetValue(stateId, out string? caption);
if (!hasFill && !hasLabel)
return false;
ActiveRetailStateId = stateId;
FrontLeft = 0;
FrontTile = spriteId;
FrontRight = 0;
if (hasFill)
{
FrontLeft = 0;
FrontTile = spriteId;
FrontRight = 0;
}
_activeStateLabel = hasLabel ? caption : null;
return true;
}
@ -169,7 +196,9 @@ public sealed class UiMeter : UiElement, IUiDatStateful
}
}
string? label = Label();
// A live provider (vitals "cur/max") wins; otherwise the active
// retail-state caption ('Height'/'Power'/'Accuracy') shows.
string? label = Label() ?? _activeStateLabel;
if (!string.IsNullOrEmpty(label))
{
if (DatFont is { } datFont)

View file

@ -116,6 +116,35 @@ public sealed class CombatUiControllerTests
Assert.True(powerLabel.RightAligned);
}
/// <summary>
/// 2026-08-14 gate: the power label's authored per-state strings (element
/// 0x10000052: 'MeleeCombat' → 'Power', 'MissileCombat' → 'Accuracy' —
/// installed-DAT probe) switch with the combat mode, retail's basic-panel
/// PassToChildren cascade. Magic mode hides the basic panel, so the label
/// keeps whatever mode set it last.
/// </summary>
[Fact]
public void PowerLabel_SwitchesToAccuracyInMissileMode_AndBack()
{
var combat = new CombatState();
using var attacks = CreateAttacks(combat, () => 0d, []);
var (layout, _, _, _, _, _, _) = BuildLayout();
var options = new FakeOptionBindings();
using var controller = CombatUiController.Bind(
layout, combat, attacks, options.ToBindings(),
Labels, _ => { })!;
var powerLabel = Assert.IsType<UiText>(
layout.FindElement(CombatUiController.PowerLabelId));
Assert.Equal("Power", powerLabel.LinesProvider!()[0].Text);
combat.SetCombatMode(CombatMode.Missile);
Assert.Equal("Accuracy", powerLabel.LinesProvider!()[0].Text);
combat.SetCombatMode(CombatMode.Melee);
Assert.Equal("Power", powerLabel.LinesProvider!()[0].Text);
}
[Fact]
public void ImportedFixture_ReflowUpdatesCenteredDarkRange()
{
@ -136,8 +165,8 @@ public sealed class CombatUiControllerTests
}
private static readonly CombatUiLabels Labels = new(
"Speed", "Power", "Repeat Attacks", "Auto Target", "Keep in View",
"High", "Medium", "Low");
"Speed", "Power", "Accuracy", "Repeat Attacks", "Auto Target",
"Keep in View", "High", "Medium", "Low");
private static RuntimeCombatAttackState CreateAttacks(
CombatState combat,

View file

@ -730,6 +730,43 @@ public class DatWidgetFactoryTests
Assert.Equal("six", lines[1].Text);
}
/// <summary>
/// 2026-08-14 gate: the stateful-fill meter (gmPowerbarUI's shape in
/// LayoutDesc 0x21000072) also absorbs its Type-12 caption child
/// (0x10000035, per-mode strings 'Height'/'Power'/'Accuracy') into
/// per-state labels latched by TrySetRetailState — the absorbed
/// equivalent of retail's PassToChildren state cascade.
/// </summary>
[Fact]
public void BuildMeter_StatefulFill_AbsorbsCaptionChildPerStateStrings()
{
var meter = new ElementInfo { Id = 0x34, Type = 7, Width = 600, Height = 15 };
meter.StateMedia[""] = (0x06004D0Bu, 1);
var fill = new ElementInfo { Id = 2, Type = 3, Width = 600, Height = 15 };
fill.States[0x10000042u] = new UiStateInfo { Id = 0x10000042u, Name = "JumpMode" };
fill.StateMedia["JumpMode"] = (0x06001354u, 1);
meter.Children.Add(fill);
var caption = new ElementInfo { Id = 0x35, Type = 12, Width = 600, Height = 15 };
var jumpText = new UiStateInfo { Id = 0x10000042u, Name = "JumpMode" };
jumpText.Properties.Values[0x17u] = new UiPropertyValue
{
Kind = UiPropertyKind.StringInfo,
StringInfoValue = new UiStringInfoValue(0, 1, 2, 0, 1, 0),
};
caption.States[0x10000042u] = jumpText;
meter.Children.Add(caption);
var m = Assert.IsType<UiMeter>(DatWidgetFactory.Create(
meter, NoTex, null, stringResolve: _ => "Height"));
Assert.Null(m.ActiveStateLabel);
Assert.True(m.TrySetRetailState(0x10000042u));
Assert.Equal("Height", m.ActiveStateLabel);
Assert.Equal(0x06001354u, m.FrontTile);
}
[Fact]
public void HorizontalScrollbar_PreservesNestedCombatMeterFillSprite()
{

View file

@ -0,0 +1,77 @@
using System.IO;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// 2026-08-14 gate probe (the ACDREAM_PROBE_LIVE_MOUNT pattern): dumps the
/// authored per-state strings of the floaty powerbar layout (0x21000072 —
/// gmPowerbarUI's mode states 0x10000042 jump / 0x10000043 melee /
/// 0x10000044 missile / 0x10000045 DDD, RecvNotice_BeginPowerbar
/// @ 0x004DA730) and the combat panel's Power label (0x21000073 element
/// 0x10000052, root states 0x10000003 melee / 0x10000004 missile) from the
/// INSTALLED DAT, so the "Height"/"Power"/"Accuracy" fix binds exactly what
/// retail authors instead of guessing.
/// </summary>
public sealed class PowerbarLayoutProbeTests
{
[Fact]
public void ProbePowerbarAuthoredStrings()
{
if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_POWERBAR") != "1")
return;
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
using var dats = new DatCollection(datDir, DatAccessType.Read);
var strings = new DatStringResolver(dats);
Dump(dats, strings, JumpPowerbarController.LayoutId, "floaty-powerbar");
Dump(dats, strings, CombatUiController.LayoutId, "combat-panel");
}
private static void Dump(
DatCollection dats,
DatStringResolver strings,
uint layoutId,
string label)
{
ElementInfo? root = LayoutImporter.ImportInfos(dats, layoutId);
if (root is null)
{
Console.WriteLine($"[pbprobe] {label} 0x{layoutId:X8}: import FAILED");
return;
}
Console.WriteLine($"[pbprobe] === {label} 0x{layoutId:X8} ===");
DumpElement(strings, root, 0);
}
private static void DumpElement(DatStringResolver strings, ElementInfo e, int depth)
{
string indent = new(' ', depth * 2);
Console.WriteLine(
$"[pbprobe] {indent}0x{e.Id:X8} type={e.Type} ({e.X},{e.Y} {e.Width}x{e.Height}) "
+ $"defaultState=0x{e.DefaultStateId:X8}('{e.DefaultStateName}') states={e.States.Count} "
+ $"hjustify={e.HJustify} fontColor={(e.FontColor is { } fc ? fc.ToString() : "none")}");
foreach (var (stateId, state) in e.States)
{
string text = "";
if (state.Properties.Values.TryGetValue(0x17u, out var p)
&& p.Kind == UiPropertyKind.StringInfo)
{
text = strings.Resolve(p.StringInfoValue) ?? "<unresolved>";
text = $" text='{text}'";
}
Console.WriteLine(
$"[pbprobe] {indent} state 0x{stateId:X8} '{state.Name}'"
+ $" passToChildren={state.PassToChildren}{text}");
}
foreach (ElementInfo child in e.Children)
DumpElement(strings, child, depth + 1);
}
}