fix(combat): restore retail combat bar controls

Resolve authored StringInfo labels from local.dat, port gmCombatUI's runtime option captions and checkbox widgets, and bind horizontal scrollbar media by retail structural roles so the green power jewel is a thumb instead of a tiled track. Persist the three combat options and make Auto Target govern target acquisition.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 20:26:52 +02:00
parent 2215c76c7e
commit 927fa7881a
19 changed files with 24591 additions and 48 deletions

View file

@ -2127,7 +2127,9 @@ public sealed class GameWindow : IDisposable
SetRetailUiLocked),
Combat: new AcDream.App.UI.CombatRuntimeBindings(
Combat,
_combatAttackController),
_combatAttackController,
() => _persistedGameplay,
SetRetailCombatGameplay),
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
Objects,
() => Shortcuts,
@ -11379,6 +11381,22 @@ public sealed class GameWindow : IDisposable
}
}
private void SetRetailCombatGameplay(
AcDream.UI.Abstractions.Panels.Settings.GameplaySettings gameplay)
{
_persistedGameplay = gameplay;
_settingsVm?.SetGameplay(gameplay);
try
{
_settingsStore?.SaveGameplay(gameplay);
}
catch (Exception ex)
{
Console.WriteLine($"settings: combat option save failed: {ex.Message}");
}
}
private void OnUiDragReleasedOutside(object payload, int x, int y)
{
if (payload is AcDream.App.UI.ItemDragPayload itemPayload)
@ -11997,6 +12015,9 @@ public sealed class GameWindow : IDisposable
if (_selection.SelectedObjectId is { } selected && IsLiveCreatureTarget(selected))
return selected;
if (!_persistedGameplay.AutoTarget)
return null;
return SelectClosestCombatTarget(showToast: false);
}

View file

@ -1,7 +1,10 @@
using AcDream.App.Rendering;
using AcDream.App.Combat;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.UI.Abstractions.Panels.Settings;
using DatReaderWriter;
namespace AcDream.App.Studio;
@ -87,6 +90,29 @@ public static class FixtureProvider
datFont: stack.VitalsDatFont);
break;
case CombatUiController.LayoutId:
{
var combat = new CombatState();
var attacks = new CombatAttackController(
combat,
canStartAttack: () => true,
sendAttack: (_, _) => true,
autoRepeatAttack: () => false);
GameplaySettings gameplay = GameplaySettings.Default;
CombatUiController.Bind(
layout,
combat,
attacks,
() => gameplay,
value => gameplay = value,
new CombatUiLabels(
"Speed", "Power", "Repeat Attacks", "Auto Target", "Keep in View",
"High", "Medium", "Low"),
visible => layout.Root.Visible = visible);
combat.SetCombatMode(CombatMode.Melee);
break;
}
case 0x21000016u: // toolbar
ToolbarController.Bind(
layout,

View file

@ -216,8 +216,10 @@ internal static class MockupDesktop
var rootInfo = LayoutImporter.ImportInfos(dats, ChatWindowController.LayoutId);
if (rootInfo is null) return null;
var strings = new DatStringResolver(dats);
var layout = LayoutImporter.Build(rootInfo, stack.ResolveChrome, stack.VitalsDatFont,
fontResolve: stack.ResolveDatFont);
fontResolve: stack.ResolveDatFont,
stringResolve: strings.Resolve);
var chatLog = new ChatLog();
chatLog.SetLocalPlayerGuid(SampleData.PlayerGuid);

View file

@ -1,5 +1,6 @@
using AcDream.App.Combat;
using AcDream.Core.Combat;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.UI.Layout;
@ -21,12 +22,17 @@ public sealed class CombatUiController : IRetainedPanelController
public const uint BasicPanelId = 0x1000005Cu;
public const uint AdvancedPanelId = 0x10000061u;
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;
private const uint MeleeState = 0x10000003u;
private const uint MissileState = 0x10000004u;
public const uint MeleeState = 0x10000003u;
public const uint MissileState = 0x10000004u;
private readonly UiElement _root;
private readonly UiElement _basicPanel;
@ -35,8 +41,13 @@ public sealed class CombatUiController : IRetainedPanelController
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 CombatAttackController _attacks;
private readonly Func<GameplaySettings> _gameplay;
private readonly Action<GameplaySettings> _setGameplay;
private readonly Action<bool> _setWindowVisible;
private bool _disposed;
@ -48,8 +59,14 @@ public sealed class CombatUiController : IRetainedPanelController
UiButton high,
UiButton medium,
UiButton low,
UiButton repeatAttacks,
UiButton autoTarget,
UiButton keepInView,
CombatState combat,
CombatAttackController attacks,
Func<GameplaySettings> gameplay,
Action<GameplaySettings> setGameplay,
CombatUiLabels labels,
Action<bool> setWindowVisible)
{
_root = layout.Root;
@ -59,8 +76,13 @@ public sealed class CombatUiController : IRetainedPanelController
_high = high;
_medium = medium;
_low = low;
_repeatAttacks = repeatAttacks;
_autoTarget = autoTarget;
_keepInView = keepInView;
_combat = combat;
_attacks = attacks;
_gameplay = gameplay;
_setGameplay = setGameplay;
_setWindowVisible = setWindowVisible;
// PlayerModule::AdvancedCombatUI false selects gmCombatUI's authored
@ -77,6 +99,22 @@ public sealed class CombatUiController : IRetainedPanelController
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;
SetStaticText(layout.FindElement(SpeedLabelId) as UiText, labels.Speed);
SetStaticText(layout.FindElement(PowerLabelId) as UiText, labels.Power);
_repeatAttacks.OnClick = () =>
_setGameplay(_gameplay() with { AutoRepeatAttack = _repeatAttacks.Selected });
_autoTarget.OnClick = () =>
_setGameplay(_gameplay() with { AutoTarget = _autoTarget.Selected });
_keepInView.OnClick = () =>
_setGameplay(_gameplay() with { ViewCombatTarget = _keepInView.Selected });
_combat.CombatModeChanged += OnCombatModeChanged;
_attacks.StateChanged += OnAttackStateChanged;
SyncControls();
@ -86,11 +124,17 @@ public sealed class CombatUiController : IRetainedPanelController
ImportedLayout layout,
CombatState combat,
CombatAttackController attacks,
Func<GameplaySettings> gameplay,
Action<GameplaySettings> setGameplay,
CombatUiLabels labels,
Action<bool> setWindowVisible)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(combat);
ArgumentNullException.ThrowIfNull(attacks);
ArgumentNullException.ThrowIfNull(gameplay);
ArgumentNullException.ThrowIfNull(setGameplay);
ArgumentNullException.ThrowIfNull(labels);
ArgumentNullException.ThrowIfNull(setWindowVisible);
if (layout.FindElement(BasicPanelId) is not { } basic
@ -98,12 +142,16 @@ public sealed class CombatUiController : IRetainedPanelController
|| 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(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, advanced, power, high, medium, low,
combat, attacks, setWindowVisible);
repeatAttacks, autoTarget, keepInView,
combat, attacks, gameplay, setGameplay, labels, setWindowVisible);
}
public void SyncVisibility() => OnCombatModeChanged(_combat.CurrentMode);
@ -136,6 +184,22 @@ public sealed class CombatUiController : IRetainedPanelController
_high.Selected = _attacks.RequestedHeight == AttackHeight.High;
_medium.Selected = _attacks.RequestedHeight == AttackHeight.Medium;
_low.Selected = _attacks.RequestedHeight == AttackHeight.Low;
GameplaySettings gameplay = _gameplay();
_repeatAttacks.Selected = gameplay.AutoRepeatAttack;
_autoTarget.Selected = gameplay.AutoTarget;
_keepInView.Selected = gameplay.ViewCombatTarget;
}
private static void SetStaticText(UiText? text, string value)
{
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;
UiText.Line[] line = [new UiText.Line(value, text.DefaultColor)];
text.LinesProvider = () => line;
}
public void OnShown() => SyncControls();
@ -154,5 +218,60 @@ public sealed class CombatUiController : IRetainedPanelController
_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>.</summary>
public sealed record CombatUiLabels(
string Speed,
string Power,
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"),
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;
}
}

View file

@ -0,0 +1,65 @@
using DatReaderWriter;
using DatReaderWriter.DBObjs;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Resolves retail <c>StringInfo</c> values through local.dat string tables.
/// The caller owns synchronization around <see cref="DatCollection"/> reads.
/// </summary>
/// <remarks>
/// Retail reference: <c>StringInfo::GetString</c> and
/// <c>compute_str_hash @ 0x00413110</c>. A StringInfo's token selects one
/// localized string variant; ordinary UI labels use token zero.
/// </remarks>
public sealed class DatStringResolver
{
private readonly DatCollection _dats;
private readonly Dictionary<uint, StringTable?> _tables = new();
public DatStringResolver(DatCollection dats)
=> _dats = dats ?? throw new ArgumentNullException(nameof(dats));
public string? Resolve(UiStringInfoValue info)
=> Resolve(info.TableId, info.StringId, info.Token);
public string? Resolve(uint tableId, uint stringId, int token = 0)
{
if (tableId == 0u || stringId == 0u)
return null;
if (!_tables.TryGetValue(tableId, out StringTable? table))
{
table = _dats.Get<StringTable>(tableId);
_tables[tableId] = table;
}
if (table is null
|| !table.Strings.TryGetValue(stringId, out var entry)
|| entry.Strings.Count == 0)
return null;
int index = token >= 0 && token < entry.Strings.Count ? token : 0;
return entry.Strings[index].Value;
}
/// <summary>
/// Exact retail ELF-style string hash used for StringInfo keys.
/// Ported line-for-line from <c>compute_str_hash @ 0x00413110</c>.
/// </summary>
public static uint ComputeHash(string value)
{
ArgumentNullException.ThrowIfNull(value);
uint result = 0u;
foreach (char c in value)
{
result = unchecked((result << 4) + (byte)c);
uint high = result & 0xF0000000u;
if (high != 0u)
result = ((high >> 24) ^ result) & 0x0FFFFFFFu;
}
return result == uint.MaxValue ? uint.MaxValue - 1u : result;
}
}

View file

@ -52,7 +52,8 @@ public static class DatWidgetFactory
/// <returns>The widget for this element. Never null — every type produces a widget.</returns>
public static UiElement? Create(ElementInfo info,
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null)
Func<uint, UiDatFont?>? fontResolve = null,
Func<UiStringInfoValue, string?>? stringResolve = null)
{
// Retail Type 3 = UIElement_Field (reg :126190), but in acdream's CURRENT layouts
// (vitals 0x2100006C / chat 0x21000006) Type-3 elements are sprite-bearing chrome +
@ -75,13 +76,14 @@ public static class DatWidgetFactory
UiElement e = info.Type switch
{
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
1 => BuildButton(info, resolve, elementFont, stringResolve), // UIElement_Button
6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
12 => BuildText(info, resolve, elementFont), // UIElement_Text (reg :115655)
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
0x10000035u => BuildCheckbox(info, resolve, elementFont, stringResolve), // UIOption_Checkbox
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
};
@ -147,20 +149,22 @@ public static class DatWidgetFactory
if (bar.Horizontal)
{
// gmToolbarUI stack slider (0x100001A4) has two direct Type-3 media
// children: a full-width 90x14 track (element 4) and a 16x14 thumb
// (element 1). Their geometry, not a button class, defines the roles.
ElementInfo? track = info.Children
.Where(child => DefaultImage(child) != 0u)
.OrderByDescending(child => child.Width)
.FirstOrDefault();
ElementInfo? scalarThumb = info.Children
.Where(child => !ReferenceEquals(child, track) && DefaultImage(child) != 0u)
.OrderBy(child => child.Width)
.FirstOrDefault();
bar.TrackSprite = track is null ? 0u : DefaultImage(track);
// Retail horizontal scrollbars use structural child ids: element 1 is
// the thumb and element 4 is the optional child-authored track.
ElementInfo? scalarThumb = info.Children.FirstOrDefault(child => child.Id == 1u);
bar.TrackSprite = DefaultImage(info);
bar.ThumbSprite = scalarThumb is null ? 0u : DefaultImage(scalarThumb);
// The toolbar stack slider authors its track on structural child 4,
// while gmCombatUI authors it on the scrollbar's DirectState. Geometry
// is never the role discriminator: inheritance can reflow child 1 and
// otherwise turn the 12px combat jewel into a tiled background.
if (bar.TrackSprite == 0u)
{
ElementInfo? authoredTrack = info.Children.FirstOrDefault(child => child.Id == 4u);
bar.TrackSprite = authoredTrack is null ? 0u : DefaultImage(authoredTrack);
}
// gmCombatUI's desired-power slider (0x1000004F) authors the
// live charge as a nested Type-7 meter. UiScrollbar consumes its
// DAT children, so retain the meter's fill image on the scalar
@ -390,7 +394,8 @@ public static class DatWidgetFactory
/// <see cref="ImportedLayout.FindElement"/> and set <see cref="UiText.DatFont"/> afterward
/// still override this — the build-time value is just the starting point.</param>
private static UiElement BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve,
UiDatFont? elementFont = null)
UiDatFont? elementFont = null,
Func<UiStringInfoValue, string?>? stringResolve = null)
{
uint bg = info.StateMedia.TryGetValue(
!string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName
@ -464,6 +469,67 @@ public static class DatWidgetFactory
if (info.FontColor.HasValue)
t.DefaultColor = info.FontColor.Value;
if (ResolveAuthoredString(info, stringResolve) is { Length: > 0 } authored)
{
UiText.Line[] line = [new UiText.Line(authored, t.DefaultColor)];
t.LinesProvider = () => line;
}
return t;
}
private static UiButton BuildButton(
ElementInfo info,
Func<uint, (uint, int, int)> resolve,
UiDatFont? elementFont,
Func<UiStringInfoValue, string?>? stringResolve)
=> new(info, resolve)
{
Label = ResolveAuthoredString(info, stringResolve),
LabelFont = elementFont,
LabelColor = info.FontColor ?? System.Numerics.Vector4.One,
};
/// <summary>
/// Retail UIOption_Checkbox is a UIElement_Button whose visible face is its
/// authored indicator child. Its label lives on the option object rather than
/// in a child UIElement_Text.
/// </summary>
private static UiButton BuildCheckbox(
ElementInfo info,
Func<uint, (uint, int, int)> resolve,
UiDatFont? elementFont,
Func<UiStringInfoValue, string?>? stringResolve)
{
ElementInfo? indicator = info.Children.FirstOrDefault(child => DefaultImage(child) != 0u);
var button = new UiButton(info, resolve, indicator)
{
Label = ResolveAuthoredString(info, stringResolve),
LabelFont = elementFont,
LabelColor = info.FontColor ?? System.Numerics.Vector4.One,
LabelAlign = UiButton.LabelAlignment.Left,
};
if (indicator is not null)
{
button.FaceLeft = indicator.X;
button.FaceTop = indicator.Y;
button.FaceWidth = indicator.Width;
button.FaceHeight = indicator.Height;
button.LabelOffsetX = indicator.X + indicator.Width + 4f;
}
return button;
}
private static string? ResolveAuthoredString(
ElementInfo info,
Func<UiStringInfoValue, string?>? stringResolve)
{
if (stringResolve is null
|| !info.TryGetEffectiveProperty(0x17u, out var property)
|| property.Kind != UiPropertyKind.StringInfo)
return null;
return stringResolve(property.StringInfoValue);
}
}

View file

@ -69,10 +69,11 @@ public static class LayoutImporter
IEnumerable<ElementInfo> children,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null)
Func<uint, UiDatFont?>? fontResolve = null,
Func<UiStringInfoValue, string?>? stringResolve = null)
{
rootInfo.Children = new List<ElementInfo>(children);
return Build(rootInfo, resolve, datFont, fontResolve);
return Build(rootInfo, resolve, datFont, fontResolve, stringResolve);
}
/// <summary>
@ -89,12 +90,13 @@ public static class LayoutImporter
ElementInfo rootInfo,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null)
Func<uint, UiDatFont?>? fontResolve = null,
Func<UiStringInfoValue, string?>? stringResolve = null)
{
var byId = new Dictionary<uint, UiElement>();
// Root is never a Type-12 prototype in practice; fall back to a generic
// container if the factory returns null for an exotic root type.
var root = BuildWidget(rootInfo, resolve, datFont, fontResolve, byId);
var root = BuildWidget(rootInfo, resolve, datFont, fontResolve, stringResolve, byId);
if (root is null)
{
Console.WriteLine($"[D.2b] LayoutImporter: root element 0x{rootInfo.Id:X8} (type {rootInfo.Type}) produced no widget — using empty container fallback.");
@ -108,9 +110,10 @@ public static class LayoutImporter
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve,
Func<UiStringInfoValue, string?>? stringResolve,
Dictionary<uint, UiElement> byId)
{
var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve);
var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve, stringResolve);
if (w is null) return null; // Type-12 style prototype — skip
if (info.Id != 0) byId[info.Id] = w;
@ -126,7 +129,7 @@ public static class LayoutImporter
{
foreach (var child in info.Children)
{
var cw = BuildWidget(child, resolve, datFont, fontResolve, byId);
var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId);
if (cw is not null) w.AddChild(cw);
}
}
@ -152,7 +155,7 @@ public static class LayoutImporter
foreach (var child in info.Children)
{
if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter
var cw = BuildWidget(child, resolve, datFont, fontResolve, byId);
var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId);
if (cw is not null) w.AddChild(cw);
}
}
@ -253,7 +256,8 @@ public static class LayoutImporter
{
var rootInfo = ImportInfos(dats, layoutId);
if (rootInfo is null) return null;
return Build(rootInfo, resolve, datFont, fontResolve);
var strings = new DatStringResolver(dats);
return Build(rootInfo, resolve, datFont, fontResolve, strings.Resolve);
}
// ── Inheritance resolution ────────────────────────────────────────────────

View file

@ -38,7 +38,9 @@ public sealed record RadarRuntimeBindings(
public sealed record CombatRuntimeBindings(
CombatState State,
CombatAttackController Attacks);
CombatAttackController Attacks,
Func<GameplaySettings> Gameplay,
Action<GameplaySettings> SetGameplay);
public sealed record ToolbarRuntimeBindings(
ClientObjectTable Objects,
@ -325,11 +327,13 @@ public sealed class RetailUiRuntime : IDisposable
lock (_bindings.Assets.DatLock)
{
info = LayoutImporter.ImportInfos(_bindings.Assets.Dats, ChatWindowController.LayoutId);
var strings = 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,
strings.Resolve);
}
if (info is null || layout is null)
{
@ -449,8 +453,22 @@ public sealed class RetailUiRuntime : IDisposable
private void MountCombat()
{
ImportedLayout? layout = Import(CombatUiController.LayoutId);
if (layout is null)
ElementInfo? info;
ImportedLayout? layout;
CombatUiLabels? labels;
lock (_bindings.Assets.DatLock)
{
info = LayoutImporter.ImportInfos(_bindings.Assets.Dats, CombatUiController.LayoutId);
var strings = new DatStringResolver(_bindings.Assets.Dats);
layout = info is null ? null : LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve);
labels = info is null ? null : CombatUiLabels.Resolve(info, strings);
}
if (layout is null || labels is null)
{
Console.WriteLine("[M2] combat: LayoutDesc 0x21000073 not found.");
return;
@ -460,6 +478,9 @@ public sealed class RetailUiRuntime : IDisposable
layout,
_bindings.Combat.State,
_bindings.Combat.Attacks,
_bindings.Combat.Gameplay,
_bindings.Combat.SetGameplay,
labels,
visible =>
{
if (visible) Host.ShowWindow(WindowNames.Combat);

View file

@ -33,6 +33,7 @@ namespace AcDream.App.UI;
public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
{
private readonly ElementInfo _info;
private readonly ElementInfo _mediaInfo;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
private readonly HashSet<uint> _availableStates = new();
private bool _pressed;
@ -86,6 +87,16 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// <summary>Label color (default white).</summary>
public Vector4 LabelColor { get; set; } = Vector4.One;
/// <summary>Optional authored face rectangle. Full-button by default; retail
/// UIOption_Checkbox uses its 13x13 indicator child as the button face.</summary>
public float FaceLeft { get; set; }
public float FaceTop { get; set; }
public float FaceWidth { get; set; }
public float FaceHeight { get; set; }
/// <summary>Additional left inset for left-aligned labels.</summary>
public float LabelOffsetX { get; set; } = 3f;
/// <summary>Horizontal alignment of <see cref="Label"/>. Center (default) for normal buttons;
/// Left for the paperdoll "Slots" caption that sits at the left edge, before the slots.</summary>
public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center;
@ -122,7 +133,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
{
if (string.IsNullOrEmpty(ActiveState))
return UiStateInfo.DirectStateId;
foreach (var (id, state) in _info.States)
foreach (var (id, state) in _mediaInfo.States)
if (string.Equals(state.Name, ActiveState, StringComparison.Ordinal))
return id;
return UiButtonStateMachine.TryStateId(ActiveState, out uint standard)
@ -164,12 +175,12 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
if (stateId == UiStateInfo.DirectStateId)
{
if (!_info.States.ContainsKey(stateId) && !_info.StateMedia.ContainsKey(""))
if (!_mediaInfo.States.ContainsKey(stateId) && !_mediaInfo.StateMedia.ContainsKey(""))
return false;
ActiveState = "";
return true;
}
if (_info.States.TryGetValue(stateId, out var state))
if (_mediaInfo.States.TryGetValue(stateId, out var state))
{
ActiveState = state.Name;
return true;
@ -177,7 +188,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
string stateName = UiButtonStateMachine.StateName(stateId);
if (string.IsNullOrEmpty(stateName))
stateName = RetailUiStateIds.StateName(stateId);
if (!string.IsNullOrEmpty(stateName) && _info.StateMedia.ContainsKey(stateName))
if (!string.IsNullOrEmpty(stateName) && _mediaInfo.StateMedia.ContainsKey(stateName))
{
ActiveState = stateName;
return true;
@ -188,9 +199,13 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
public UiButton(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
public UiButton(
ElementInfo info,
Func<uint, (uint tex, int w, int h)> resolve,
ElementInfo? mediaInfo = null)
{
_info = info;
_mediaInfo = mediaInfo ?? info;
_resolve = resolve;
ClickThrough = false; // buttons are interactive — opt OUT of click-through
@ -198,7 +213,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
// Retail layouts commonly declare an empty Normal_pressed descriptor while
// supplying art only for Normal/Highlight. Treating that property-only state
// as drawable briefly blanks the button during mouse-down.
foreach (string stateName in info.StateMedia.Keys)
foreach (string stateName in _mediaInfo.StateMedia.Keys)
if (UiButtonStateMachine.TryStateId(stateName, out uint stateId))
_availableStates.Add(stateId);
@ -218,11 +233,13 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
// DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState ("").
if (!string.IsNullOrEmpty(info.DefaultStateName))
ActiveState = info.DefaultStateName;
else if (info.StateMedia.ContainsKey("Normal"))
else if (_mediaInfo.StateMedia.ContainsKey("Normal"))
ActiveState = "Normal";
// else ActiveState stays "" (DirectState)
Enabled = !disabled;
FaceWidth = mediaInfo?.Width ?? info.Width;
FaceHeight = mediaInfo?.Height ?? info.Height;
UpdateVisualState();
}
@ -242,8 +259,8 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// Mirrors <see cref="UiDatElement.ActiveMedia()"/>.
/// </summary>
private uint ActiveFile()
=> _info.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
: _info.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
=> _mediaInfo.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
: _mediaInfo.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
protected override void OnDraw(UiRenderContext ctx)
{
@ -255,14 +272,17 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
{
// Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped
// UI texture). Matches ImgTex::TileCSI; no Stretch mode exists.
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
float faceWidth = FaceWidth > 0f ? FaceWidth : Width;
float faceHeight = FaceHeight > 0f ? FaceHeight : Height;
ctx.DrawSprite(tex, FaceLeft, FaceTop, faceWidth, faceHeight,
0, 0, faceWidth / tw, faceHeight / th, Vector4.One);
}
}
if (Label is { Length: > 0 } label && LabelFont is { } lf)
{
float tx = LabelAlign == LabelAlignment.Left
? 3f // small left pad (room for a future checkbox box)
? LabelOffsetX
: (Width - lf.MeasureWidth(label)) * 0.5f; // centered (default)
float ty = (Height - lf.LineHeight) * 0.5f;
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);