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>
328 lines
14 KiB
C#
328 lines
14 KiB
C#
using AcDream.Core.Combat;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Runtime.Gameplay;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Binds the imported retail <c>gmCombatUI</c> layout to the client combat
|
|
/// state machine. No panel geometry is synthesized here: every control is the
|
|
/// authored element from LayoutDesc <c>0x21000073</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Retail references: <c>gmCombatUI::RecvNotice_AttackHeightChanged</c>
|
|
/// (0x004CC080), <c>RecvNotice_SetPowerbarLevel</c> (0x004CC0E0),
|
|
/// <c>RecvNotice_DesiredAttackPowerChanged</c> (0x004CC110),
|
|
/// <c>ListenToElementMessage</c> (0x004CC430), and
|
|
/// <c>RecvNotice_SetCombatMode</c> (0x004CC620).
|
|
///
|
|
/// <para>
|
|
/// MUST-FIX 3 (OP4 review-fix round, 2026-08-11, blast M2): the three
|
|
/// LEDs (Repeat Attacks / Auto Target / Keep in View) read and write
|
|
/// through the SAME <c>RuntimeCharacterOptionsState</c> server-bit seam
|
|
/// the Character tab's rows for these same retail <c>PlayerOption</c>s
|
|
/// already use — retired the client-local <c>GameplaySettings</c> mirror
|
|
/// this panel used to read/write, which had silently diverged into a
|
|
/// second, disconnected copy of the same three options (register row
|
|
/// AP-196).
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class CombatUiController : IRetainedPanelController
|
|
{
|
|
/// <summary>The live read/write seam for the three combat LEDs —
|
|
/// identical shape to <see cref="CharacterOptionsPageController.Bindings"/>.</summary>
|
|
public sealed record Bindings(
|
|
Func<CharacterOptionId, bool> CurrentValue,
|
|
Action<CharacterOptionId, bool> SetOption);
|
|
|
|
public const uint LayoutId = 0x21000073u;
|
|
public const uint BasicPanelId = 0x1000005Cu;
|
|
public const uint SpellcastingPanelId = 0x10000061u;
|
|
public const uint AdvancedPanelId = SpellcastingPanelId;
|
|
public const uint PowerControlId = 0x1000004Fu;
|
|
public const uint SpeedLabelId = 0x10000051u;
|
|
public const uint PowerLabelId = 0x10000052u;
|
|
public const uint RepeatAttacksId = 0x10000053u;
|
|
public const uint AutoTargetId = 0x10000054u;
|
|
public const uint KeepInViewId = 0x10000055u;
|
|
public const uint HighButtonId = 0x10000057u;
|
|
public const uint MediumButtonId = 0x10000058u;
|
|
public const uint LowButtonId = 0x10000059u;
|
|
|
|
public const uint MeleeState = 0x10000003u;
|
|
public const uint MissileState = 0x10000004u;
|
|
|
|
private readonly UiElement _root;
|
|
private readonly UiElement _basicPanel;
|
|
private readonly UiElement _spellcastingPanel;
|
|
private readonly UiScrollbar _powerControl;
|
|
private readonly UiButton _high;
|
|
private readonly UiButton _medium;
|
|
private readonly UiButton _low;
|
|
private readonly UiButton _repeatAttacks;
|
|
private readonly UiButton _autoTarget;
|
|
private readonly UiButton _keepInView;
|
|
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;
|
|
|
|
private CombatUiController(
|
|
ImportedLayout layout,
|
|
UiElement basicPanel,
|
|
UiElement spellcastingPanel,
|
|
UiScrollbar powerControl,
|
|
UiButton high,
|
|
UiButton medium,
|
|
UiButton low,
|
|
UiButton repeatAttacks,
|
|
UiButton autoTarget,
|
|
UiButton keepInView,
|
|
CombatState combat,
|
|
RuntimeCombatAttackState attacks,
|
|
Bindings bindings,
|
|
CombatUiLabels labels,
|
|
Action<bool> setWindowVisible)
|
|
{
|
|
_root = layout.Root;
|
|
_basicPanel = basicPanel;
|
|
_spellcastingPanel = spellcastingPanel;
|
|
_powerControl = powerControl;
|
|
_high = high;
|
|
_medium = medium;
|
|
_low = low;
|
|
_repeatAttacks = repeatAttacks;
|
|
_autoTarget = autoTarget;
|
|
_keepInView = keepInView;
|
|
_combat = combat;
|
|
_attacks = attacks;
|
|
_bindings = bindings;
|
|
_labels = labels;
|
|
_setWindowVisible = setWindowVisible;
|
|
|
|
// Retail layout 0x21000073 contains two sibling pages: gmCombatUI's
|
|
// physical-attack controls and gmSpellcastingUI's favorite-spell bar.
|
|
_basicPanel.Visible = true;
|
|
_spellcastingPanel.Visible = false;
|
|
|
|
_powerControl.SetScalarPosition(_attacks.DesiredPower);
|
|
_powerControl.ScalarChanged = _attacks.SetDesiredPower;
|
|
_powerControl.ScalarFill = () => _attacks.PowerBarLevel;
|
|
|
|
BindAttackButton(_high, AttackHeight.High);
|
|
BindAttackButton(_medium, AttackHeight.Medium);
|
|
BindAttackButton(_low, AttackHeight.Low);
|
|
|
|
_high.Label = labels.High;
|
|
_medium.Label = labels.Medium;
|
|
_low.Label = labels.Low;
|
|
_repeatAttacks.Label = labels.RepeatAttacks;
|
|
_autoTarget.Label = labels.AutoTarget;
|
|
_keepInView.Label = labels.KeepInView;
|
|
UiText? speedLabel = layout.FindElement(SpeedLabelId) as UiText;
|
|
_powerLabel = layout.FindElement(PowerLabelId) as UiText;
|
|
SetStaticText(speedLabel, labels.Speed, rightAligned: false);
|
|
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
|
|
// these same retail PlayerOptions use — TrySetOption writes the
|
|
// local bit first, then either sends 0x0005 immediately
|
|
// (AutoRepeatAttack/AutoTarget are auto-save) or marks the batched
|
|
// module dirty (ViewCombatTarget is batched).
|
|
_repeatAttacks.OnClick = () =>
|
|
_bindings.SetOption(CharacterOptionId.AutoRepeatAttack, _repeatAttacks.Selected);
|
|
_autoTarget.OnClick = () =>
|
|
_bindings.SetOption(CharacterOptionId.AutoTarget, _autoTarget.Selected);
|
|
_keepInView.OnClick = () =>
|
|
_bindings.SetOption(CharacterOptionId.ViewCombatTarget, _keepInView.Selected);
|
|
|
|
_combat.CombatModeChanged += OnCombatModeChanged;
|
|
_attacks.StateChanged += OnAttackStateChanged;
|
|
SyncControls();
|
|
}
|
|
|
|
public static CombatUiController? Bind(
|
|
ImportedLayout layout,
|
|
CombatState combat,
|
|
RuntimeCombatAttackState attacks,
|
|
Bindings bindings,
|
|
CombatUiLabels labels,
|
|
Action<bool> setWindowVisible)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(layout);
|
|
ArgumentNullException.ThrowIfNull(combat);
|
|
ArgumentNullException.ThrowIfNull(attacks);
|
|
ArgumentNullException.ThrowIfNull(bindings);
|
|
ArgumentNullException.ThrowIfNull(labels);
|
|
ArgumentNullException.ThrowIfNull(setWindowVisible);
|
|
|
|
if (layout.FindElement(BasicPanelId) is not { } basic
|
|
|| layout.FindElement(SpellcastingPanelId) is not { } spellcasting
|
|
|| layout.FindElement(PowerControlId) is not UiScrollbar power
|
|
|| layout.FindElement(HighButtonId) is not UiButton high
|
|
|| layout.FindElement(MediumButtonId) is not UiButton medium
|
|
|| layout.FindElement(LowButtonId) is not UiButton low
|
|
|| layout.FindElement(RepeatAttacksId) is not UiButton repeatAttacks
|
|
|| layout.FindElement(AutoTargetId) is not UiButton autoTarget
|
|
|| layout.FindElement(KeepInViewId) is not UiButton keepInView)
|
|
return null;
|
|
|
|
return new CombatUiController(
|
|
layout, basic, spellcasting, power, high, medium, low,
|
|
repeatAttacks, autoTarget, keepInView,
|
|
combat, attacks, bindings, labels, setWindowVisible);
|
|
}
|
|
|
|
public void SyncVisibility() => OnCombatModeChanged(_combat.CurrentMode);
|
|
|
|
private void BindAttackButton(UiButton button, AttackHeight height)
|
|
{
|
|
button.OnPressed = () => _attacks.PressAttack(height);
|
|
button.OnReleased = _attacks.ReleaseAttack;
|
|
}
|
|
|
|
private void OnCombatModeChanged(CombatMode mode)
|
|
{
|
|
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;
|
|
// 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();
|
|
}
|
|
|
|
private void OnAttackStateChanged() => SyncControls();
|
|
|
|
/// <summary>OP4 re-review R2: a fresh PlayerDescription seed landed —
|
|
/// the three option LEDs re-read the live bits so an OPEN combat panel
|
|
/// converges with the Character tab instead of waiting for the next
|
|
/// show/mode/attack-state change.</summary>
|
|
public void OnServerOptionsSeeded() => SyncControls();
|
|
|
|
private void SyncControls()
|
|
{
|
|
_powerControl.SetScalarPosition(_attacks.DesiredPower);
|
|
_high.Selected = _attacks.RequestedHeight == AttackHeight.High;
|
|
_medium.Selected = _attacks.RequestedHeight == AttackHeight.Medium;
|
|
_low.Selected = _attacks.RequestedHeight == AttackHeight.Low;
|
|
_repeatAttacks.Selected = _bindings.CurrentValue(CharacterOptionId.AutoRepeatAttack);
|
|
_autoTarget.Selected = _bindings.CurrentValue(CharacterOptionId.AutoTarget);
|
|
_keepInView.Selected = _bindings.CurrentValue(CharacterOptionId.ViewCombatTarget);
|
|
}
|
|
|
|
private static void SetStaticText(UiText? text, string value, bool rightAligned)
|
|
{
|
|
if (text is null) return;
|
|
// Retail UIElement_Text starts with zero margins; these 15px-high
|
|
// gmCombatUI captions are single authored lines. The scrollback path's
|
|
// generic 4px inset would leave less than one glyph row and clip them all.
|
|
text.OneLine = true;
|
|
text.Padding = 0f;
|
|
text.Centered = false;
|
|
text.RightAligned = rightAligned;
|
|
UiText.Line[] line = [new UiText.Line(value, text.DefaultColor)];
|
|
text.LinesProvider = () => line;
|
|
}
|
|
|
|
public void OnShown() => SyncControls();
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
_combat.CombatModeChanged -= OnCombatModeChanged;
|
|
_attacks.StateChanged -= OnAttackStateChanged;
|
|
_powerControl.ScalarChanged = null;
|
|
_powerControl.ScalarFill = () => null;
|
|
_high.OnPressed = null;
|
|
_high.OnReleased = null;
|
|
_medium.OnPressed = null;
|
|
_medium.OnReleased = null;
|
|
_low.OnPressed = null;
|
|
_low.OnReleased = null;
|
|
_repeatAttacks.OnClick = null;
|
|
_autoTarget.OnClick = null;
|
|
_keepInView.OnClick = null;
|
|
}
|
|
}
|
|
|
|
/// <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,
|
|
string High,
|
|
string Medium,
|
|
string Low)
|
|
{
|
|
private const uint UiStringTable = 0x23000001u;
|
|
|
|
public static CombatUiLabels Resolve(ElementInfo root, DatStringResolver strings)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(root);
|
|
ArgumentNullException.ThrowIfNull(strings);
|
|
|
|
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"),
|
|
ElementString(CombatUiController.HighButtonId, UiStateInfo.DirectStateId, "High"),
|
|
ElementString(CombatUiController.MediumButtonId, UiStateInfo.DirectStateId, "Medium"),
|
|
ElementString(CombatUiController.LowButtonId, UiStateInfo.DirectStateId, "Low"));
|
|
|
|
string RuntimeString(string id, string fallback)
|
|
=> strings.Resolve(UiStringTable, DatStringResolver.ComputeHash(id)) ?? fallback;
|
|
|
|
string ElementString(uint elementId, uint stateId, string fallback)
|
|
{
|
|
ElementInfo? element = Find(root, elementId);
|
|
if (element is null
|
|
|| !element.TryGetEffectiveProperty(0x17u, out var property, stateId)
|
|
|| property.Kind != UiPropertyKind.StringInfo)
|
|
return fallback;
|
|
return strings.Resolve(property.StringInfoValue) ?? fallback;
|
|
}
|
|
}
|
|
|
|
private static ElementInfo? Find(ElementInfo element, uint id)
|
|
{
|
|
if (element.Id == id) return element;
|
|
foreach (ElementInfo child in element.Children)
|
|
if (Find(child, id) is { } found)
|
|
return found;
|
|
return null;
|
|
}
|
|
}
|