diff --git a/src/AcDream.App/UI/Layout/CombatUiController.cs b/src/AcDream.App/UI/Layout/CombatUiController.cs index b8b45e9b..e5f30de2 100644 --- a/src/AcDream.App/UI/Layout/CombatUiController.cs +++ b/src/AcDream.App/UI/Layout/CombatUiController.cs @@ -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 _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 } } -/// Localized labels assigned by retail gmCombatUI::PostInit. +/// Localized labels assigned by retail gmCombatUI::PostInit. +/// / 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. 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"), diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 138f0370..3746befa 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -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 /// /// private static UiMeter BuildMeter(ElementInfo info, - Func resolve, UiDatFont? datFont) + Func resolve, UiDatFont? datFont, + Func? 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 { diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index b48f2263..4d814eb6 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -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) { diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs index 6546eee3..e667b4b2 100644 --- a/src/AcDream.App/UI/UiMeter.cs +++ b/src/AcDream.App/UI/UiMeter.cs @@ -17,6 +17,8 @@ namespace AcDream.App.UI; public sealed class UiMeter : UiElement, IUiDatStateful { private readonly Dictionary _stateFillSprites = new(); + private readonly Dictionary _stateLabels = new(); + private string? _activeStateLabel; /// Dat element id, set by the layout importer so duplicated page copies can be scoped. public uint ElementId { get; set; } @@ -72,6 +74,10 @@ public sealed class UiMeter : UiElement, IUiDatStateful /// The active numeric DAT state for a stateful fill meter. public uint ActiveRetailStateId { get; private set; } + /// The caption latched by the active retail state (null when the + /// state authors none). Exposed for tests. + internal string? ActiveStateLabel => _activeStateLabel; + /// /// 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; } + /// + /// 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 + /// and draws it when no live + /// provider supplies text. The child authors + /// HJustify=Center — the same centered draw the label overlay uses. + /// + 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) diff --git a/tests/AcDream.App.Tests/UI/Layout/CombatUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CombatUiControllerTests.cs index faa7642d..c4e09c2f 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CombatUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CombatUiControllerTests.cs @@ -116,6 +116,35 @@ public sealed class CombatUiControllerTests Assert.True(powerLabel.RightAligned); } + /// + /// 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. + /// + [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( + 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, diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 7a12c75d..10a7f96a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -730,6 +730,43 @@ public class DatWidgetFactoryTests Assert.Equal("six", lines[1].Text); } + /// + /// 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. + /// + [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(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() { diff --git a/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs new file mode 100644 index 00000000..736a3432 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs @@ -0,0 +1,77 @@ +using System.IO; +using AcDream.App.UI.Layout; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// 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. +/// +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) ?? ""; + 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); + } +}