feat(mosstank): add VTank-style automation PoC

This commit is contained in:
Erik 2026-08-27 18:57:21 +02:00
parent f6fe0f2a4f
commit 4e6e9bc9d9
212 changed files with 49462 additions and 416 deletions

View file

@ -75,6 +75,7 @@ public sealed class ItemInteractionController : IDisposable
// dispatched-vs-silent-no-op shape as _sendBuy — see TryBuyAll/TrySell.
private readonly Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? _sendBuyAll;
private readonly Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? _sendSell;
private readonly Func<uint, IReadOnlyList<uint>, bool>? _sendSalvage;
private readonly RuntimeInteractionTransactionState _runtimeTransactions;
private readonly InventoryTransactionState _transactions;
@ -120,7 +121,8 @@ public sealed class ItemInteractionController : IDisposable
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? sendBuyAll = null,
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? sendSell = null,
Action<string, RetailLogTextType>? interfaceText = null,
Action<uint, uint, uint>? sendStackableMerge = null)
Action<uint, uint, uint>? sendStackableMerge = null,
Func<uint, IReadOnlyList<uint>, bool>? sendSalvage = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -155,6 +157,7 @@ public sealed class ItemInteractionController : IDisposable
_sendBuy = sendBuy;
_sendBuyAll = sendBuyAll;
_sendSell = sendSell;
_sendSalvage = sendSalvage;
_interactionState = interactionState
?? throw new ArgumentNullException(nameof(interactionState));
_runtimeTransactions = runtimeTransactions
@ -500,6 +503,197 @@ public sealed class ItemInteractionController : IDisposable
});
}
/// <summary>
/// Plugin-facing form of retail's put/split-to-container attempts. It
/// borrows this controller's exact transaction gate and wire delegates;
/// plugins supply policy, never a second optimistic inventory model.
/// </summary>
public bool TryMoveItemForAutomation(
uint itemId,
uint containerId,
uint amount = 0u,
int placement = 0)
{
if (itemId == 0u
|| containerId == 0u
|| _sendPutItemInContainer is null
|| _objects.Get(itemId) is not { } item
|| !IsOwnedByPlayer(itemId)
|| (containerId != _playerGuid() && !IsOwnedByPlayer(containerId)))
{
return false;
}
uint fullStack = (uint)Math.Max(1, item.StackSize);
uint requested = amount == 0u ? fullStack : amount;
if (requested == 0u || requested > fullStack)
return false;
if (requested < fullStack)
{
return TrySplitToContainer(
itemId,
containerId,
(uint)Math.Max(0, placement),
requested);
}
return TryDispatchInventoryRequest(
InventoryRequestKind.PutInContainer,
itemId,
() =>
{
_sendPutItemInContainer(itemId, containerId, placement);
return true;
});
}
/// <summary>
/// Plugin-facing retail stack merge. The shared planner performs the same
/// WCID, maximum-size, staged-trade, and transfer-size checks as a drag.
/// </summary>
public bool TryMergeItemsForAutomation(
uint sourceItemId,
uint targetItemId,
uint amount = 0u)
{
if (_sendStackableMerge is null
|| !IsOwnedByPlayer(sourceItemId)
|| !IsOwnedByPlayer(targetItemId)
|| _objects.Get(sourceItemId) is not { } source
|| _objects.Get(targetItemId) is not { } target)
{
return false;
}
int requested = amount > int.MaxValue ? int.MaxValue : (int)amount;
StackMergePlan? plan = StackMergePlanner.Plan(
ToStackMergeItem(source),
ToStackMergeItem(target),
CanMakeInventoryRequest,
requested);
if (plan is not { } merge)
return false;
return TryDispatchInventoryRequest(
InventoryRequestKind.Merge,
sourceItemId,
() =>
{
_sendStackableMerge(
merge.SourceObjectId,
merge.TargetObjectId,
merge.Amount);
MergeAttempted?.Invoke(
merge.SourceObjectId,
merge.TargetObjectId);
return true;
});
}
/// <summary>Plugin-facing retail full-stack drop or split-to-world.</summary>
public bool TryDropItemForAutomation(uint itemId, uint amount = 0u)
{
if (!IsOwnedByPlayer(itemId) || _objects.Get(itemId) is not { } item)
return false;
uint fullStack = (uint)Math.Max(1, item.StackSize);
uint requested = amount == 0u ? fullStack : amount;
if (requested == 0u || requested > fullStack)
return false;
InventoryRequestKind kind = requested < fullStack
? InventoryRequestKind.SplitToWorld
: InventoryRequestKind.DropToWorld;
return TryDispatchInventoryRequest(
kind,
itemId,
() =>
{
if (requested < fullStack)
{
if (_sendSplitToWorld is null)
return false;
_sendSplitToWorld(itemId, requested);
}
else
{
if (_sendDrop is null)
return false;
_sendDrop(itemId);
}
return true;
});
}
/// <summary>Plugin-facing retail Give attempt with an exact stack amount.</summary>
public bool TryGiveItemForAutomation(
uint itemId,
uint targetId,
uint amount = 0u)
{
if (_sendGive is null
|| targetId == 0u
|| targetId == _playerGuid()
|| _objects.Get(targetId) is null
|| !IsOwnedByPlayer(itemId)
|| _objects.Get(itemId) is not { } item)
{
return false;
}
uint fullStack = (uint)Math.Max(1, item.StackSize);
uint requested = amount == 0u ? fullStack : amount;
if (requested == 0u || requested > fullStack)
return false;
return TryDispatchInventoryRequest(
InventoryRequestKind.Give,
itemId,
() =>
{
_sendGive(targetId, itemId, requested);
return true;
});
}
/// <summary>
/// Plugin-facing form of gmSalvageUI::Salvage. Retail validates an owned
/// tinkering tool and a non-empty ordered list of suitable owned source
/// items, then sends 0x027D without entering the ordinary one-item move
/// transaction. The server owns the final material and option checks.
/// </summary>
public bool TrySalvageItemsForAutomation(
uint toolId,
IReadOnlyList<uint> itemIds)
{
if (_sendSalvage is null
|| toolId == 0u
|| itemIds is null
|| itemIds.Count == 0
|| !CanMakeInventoryRequest
|| !IsOwnedByPlayer(toolId)
|| _objects.Get(toolId) is not { } tool
|| (tool.Type & ItemType.TinkeringTool) == 0)
{
return false;
}
var distinct = new HashSet<uint>();
foreach (uint itemId in itemIds)
{
if (itemId == 0u
|| itemId == toolId
|| !distinct.Add(itemId)
|| !IsOwnedByPlayer(itemId)
|| _objects.Get(itemId) is not { } item
|| item.MaterialType is null or 0u
|| item.Structure >= 100
|| ((item.PublicWeenieBitfield ?? 0u) & 0xFF000000u) != 0u)
{
return false;
}
}
return _sendSalvage(toolId, itemIds);
}
/// <summary>
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a
/// request issued by another retained controller has been sent. The
@ -655,6 +849,21 @@ public sealed class ItemInteractionController : IDisposable
_runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine);
}
/// <summary>
/// Plugin/automation appraisal through the one retail appraisal owner.
/// It does not mutate selection or open/raise the examination window.
/// </summary>
public bool TryAppraiseForAutomation(uint objectId)
{
if (objectId == 0u
|| _sendExamine is null
|| _objects.Get(objectId) is null)
{
return false;
}
return _runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine);
}
/// <summary>
/// Accepts only the pending or current appraisal, matching
/// <c>gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0</c>.
@ -750,6 +959,76 @@ public sealed class ItemInteractionController : IDisposable
return ExecuteUseActions(decision.Actions);
}
/// <summary>
/// Plugin/automation entry for an ordinary item request. Unlike interactive
/// activation, it never turns a use request into wielding, sorting, or a
/// modal target cursor, and returns true only when a wire Use was issued.
/// </summary>
public bool TryUseItemForAutomation(uint itemGuid)
{
if (itemGuid == 0u || _objects.Get(itemGuid) is not { } item)
return false;
if (ItemUseability.IsTargeted(item.Useability ?? ItemUseability.Undef))
return false;
if (!ConsumeUseThrottle())
return false;
if (!EnsureInventoryRequestReady())
return false;
var input = new ItemUsePolicyInput(
Snapshot(item),
_playerGuid(),
_groundObjectId(),
CanMakeInventoryRequest,
_activeVendorId(),
BypassClassification: true,
UseCurrentSelection: false,
SelectedTarget: null,
ConfirmVolatileRareUses: true,
InNonCombatMode: _inNonCombatMode());
ItemUsePolicyDecision decision = ItemInteractionPolicy.DecideUse(input);
bool sends = decision.Actions.Any(static action =>
action.Kind == ItemPolicyActionKind.SendUse);
return sends && ExecuteUseActions(decision.Actions);
}
/// <summary>
/// Plugin/automation entry for a targeted item action. It follows the same
/// retail compatibility, throttle, busy-reference and UseDone ownership as
/// choosing a target through the interactive target cursor, without
/// installing a modal cursor state that automation cannot safely own.
/// </summary>
public bool TryApplyItem(uint itemGuid, uint targetGuid)
{
if (itemGuid == 0u || targetGuid == 0u)
return false;
if (_objects.Get(itemGuid) is not { } item
|| _objects.Get(targetGuid) is not { } target)
{
return false;
}
if (!ConsumeUseThrottle())
return true;
if (!EnsureInventoryRequestReady())
return false;
var input = new ItemUsePolicyInput(
Snapshot(item),
_playerGuid(),
_groundObjectId(),
CanMakeInventoryRequest,
_activeVendorId(),
BypassClassification: true,
UseCurrentSelection: true,
SelectedTarget: Snapshot(target),
ConfirmVolatileRareUses: true,
InNonCombatMode: _inNonCombatMode());
ItemUsePolicyDecision decision = ItemInteractionPolicy.DecideUse(input);
bool sends = decision.Actions.Any(static action =>
action.Kind == ItemPolicyActionKind.SendUseWithTarget);
return sends && ExecuteUseActions(decision.Actions);
}
/// <summary>
/// Retail keyboard pickup entry point. <c>CPlayerSystem::PlaceInBackpack</c>
/// publishes the waiting destination slot before issuing the move request,
@ -1095,6 +1374,24 @@ public sealed class ItemInteractionController : IDisposable
return _autoWield.TryWield(item, targetMask);
}
/// <summary>
/// Plugin/automation entry into the exact same AutoWield transaction used
/// by inventory activation and paperdoll drops.
/// </summary>
public bool TryWieldItem(uint itemGuid, EquipMask requestedMask = EquipMask.None)
{
if (itemGuid == 0u || _objects.Get(itemGuid) is not { } item)
return false;
if (!EnsureInventoryRequestReady())
return false;
return requestedMask == EquipMask.None
? _autoWield.TryWield(item)
: _autoWield.TryWield(item, requestedMask);
}
public bool IsAutoWieldBusy =>
_autoWield.IsBusy || !_transactions.CanBeginRequest;
/// <summary>User combat-mode input supersedes AutoWield's retained mode.</summary>
public void NotifyExplicitCombatModeRequest()
=> _autoWield.NotifyExplicitCombatModeRequest();

View file

@ -0,0 +1,137 @@
using System.Numerics;
using AcDream.Core.Selection;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retained-view projection of VTank's <c>ShowCollisionDebug</c> shapes.
/// The collision query remains in the canonical physics world; this owner
/// only projects its detached per-quantum samples into the already-open UI
/// phase, avoiding a nested Vulkan backbuffer pass.
/// </summary>
internal sealed class ProjectileDebugOverlayController
{
private static readonly Vector4 ClearColor = new(0f, 1f, 0f, 0.95f);
private static readonly Vector4 BlockedColor = new(1f, 0f, 0f, 0.95f);
private readonly UiPanel _root;
private readonly Func<IReadOnlyList<PluginProjectileDebugSample>> _samples;
private readonly Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)>
_camera;
private readonly List<UiPanel> _markers = [];
private ProjectileDebugOverlayController(
UiPanel root,
Func<IReadOnlyList<PluginProjectileDebugSample>> samples,
Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> camera)
{
_root = root;
_samples = samples;
_camera = camera;
}
internal static ProjectileDebugOverlayController Mount(
UiRoot host,
Func<IReadOnlyList<PluginProjectileDebugSample>> samples,
Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> camera)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(samples);
ArgumentNullException.ThrowIfNull(camera);
var root = new UiPanel
{
Name = "PluginProjectileDebugOverlay",
BackgroundColor = Vector4.Zero,
BorderColor = Vector4.Zero,
ClickThrough = true,
Visible = false,
ZOrder = -9_999,
Anchors = AnchorEdges.None,
};
host.AddChild(root);
return new ProjectileDebugOverlayController(root, samples, camera);
}
internal void Tick()
{
IReadOnlyList<PluginProjectileDebugSample> samples = _samples();
var camera = _camera();
if (samples.Count == 0
|| camera.Viewport.X <= 0f
|| camera.Viewport.Y <= 0f)
{
HideAll();
return;
}
EnsureMarkerCount(samples.Count);
_root.Left = 0f;
_root.Top = 0f;
_root.Width = camera.Viewport.X;
_root.Height = camera.Viewport.Y;
int visible = 0;
for (int index = 0; index < samples.Count; index++)
{
PluginProjectileDebugSample sample = samples[index];
if (!ScreenProjection.TryProjectSphereToScreenRect(
sample.WorldPosition,
sample.Radius,
camera.View,
camera.Projection,
camera.Viewport,
out Vector2 minimum,
out Vector2 maximum,
out _,
minSidePixels: 4f)
|| maximum.X < 0f
|| maximum.Y < 0f
|| minimum.X > camera.Viewport.X
|| minimum.Y > camera.Viewport.Y)
{
continue;
}
UiPanel marker = _markers[visible++];
marker.Left = MathF.Max(0f, minimum.X);
marker.Top = MathF.Max(0f, minimum.Y);
marker.Width = MathF.Max(
1f,
MathF.Min(camera.Viewport.X, maximum.X) - marker.Left);
marker.Height = MathF.Max(
1f,
MathF.Min(camera.Viewport.Y, maximum.Y) - marker.Top);
marker.BorderColor = sample.IsClear ? ClearColor : BlockedColor;
marker.Visible = true;
}
for (int index = visible; index < _markers.Count; index++)
_markers[index].Visible = false;
_root.Visible = visible > 0;
}
private void EnsureMarkerCount(int count)
{
while (_markers.Count < count)
{
var marker = new UiPanel
{
Name = $"PluginProjectileDebugMarker{_markers.Count}",
BackgroundColor = Vector4.Zero,
BorderColor = ClearColor,
BorderThickness = 1.5f,
ClickThrough = true,
Visible = false,
Anchors = AnchorEdges.None,
};
_markers.Add(marker);
_root.AddChild(marker);
}
}
private void HideAll()
{
_root.Visible = false;
for (int index = 0; index < _markers.Count; index++)
_markers[index].Visible = false;
}
}

View file

@ -14,6 +14,12 @@ namespace AcDream.App.UI;
/// </summary>
public static class MarkupDocument
{
// Retail's generic runtime-text tooltip skin. Plugin controls have no
// LayoutDesc of their own, so a tooltip= attribute explicitly opts them
// into the same popup that game-code SetTooltip call sites use.
private const uint RuntimeTooltipRootElementId = 0x10000397u;
private const uint RuntimeTooltipLayoutDid = 0x21000041u;
/// <param name="xml">Raw XML markup for a single panel.</param>
/// <param name="binding">Object whose public properties are bound to <c>{PropName}</c> attributes.</param>
/// <param name="resolve">Surface id → (GL handle, width, height) for chrome sprites.</param>
@ -74,13 +80,47 @@ public static class MarkupDocument
}
foreach (var el in root.Elements())
AddElement(panel, el, binding, resolve, datFont);
return panel;
}
private static void AddElement(
UiElement parent,
XElement el,
object binding,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont)
{
switch (el.Name.LocalName)
{
switch (el.Name.LocalName)
{
case "meter":
case "group":
var group = new UiPanel
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
BackgroundColor = el.Attribute("background") is null
? Vector4.Zero
: Color((string?)el.Attribute("background")),
BorderColor = el.Attribute("border") is null
? Vector4.Zero
: Color((string?)el.Attribute("border")),
BorderThickness = el.Attribute("border") is null ? 0f : 1f,
// Transparent layout groups do not claim empty space, while
// their interactive descendants remain hittable.
ClickThrough = true,
};
ApplyCommon(group, el, binding);
parent.AddChild(group);
foreach (XElement child in el.Elements())
AddElement(group, child, binding, resolve, datFont);
break;
case "meter":
var cur = BindUint((string?)el.Attribute("cur"), binding);
var max = BindUint((string?)el.Attribute("max"), binding);
panel.AddChild(new UiMeter
var meter = new UiMeter
{
Left = F(el, "x"),
Top = F(el, "y"),
@ -97,10 +137,12 @@ public static class MarkupDocument
FrontLeft = Hex((string?)el.Attribute("frontleft")),
FrontTile = Hex((string?)el.Attribute("fronttile")),
FrontRight = Hex((string?)el.Attribute("frontright")),
});
};
ApplyCommon(meter, el, binding);
parent.AddChild(meter);
break;
case "label":
case "label":
// Text may be a literal or a {Binding}. Bound labels re-read
// their property every frame through the Func, so a plugin
// updates its status line by assigning a property rather
@ -114,10 +156,11 @@ public static class MarkupDocument
};
if (el.Attribute("color") is not null)
label.TextColor = Color((string?)el.Attribute("color"));
panel.AddChild(label);
ApplyCommon(label, el, binding);
parent.AddChild(label);
break;
case "button":
case "button":
// onclick binds to an Action property on the binding
// object. Resolved once at build time: a button whose
// handler silently failed to bind is a bug worth failing
@ -151,13 +194,243 @@ public static class MarkupDocument
button.TextSource = BindString(caption, binding);
if (el.Attribute("color") is not null)
button.TextColor = Color((string?)el.Attribute("color"));
if (el.Attribute("background") is not null)
button.BackgroundColor = Color(
(string?)el.Attribute("background"));
if (el.Attribute("border") is not null)
button.BorderColor = Color(
(string?)el.Attribute("border"));
ApplyCommon(button, el, binding);
if (onClick is not null)
button.Click += onClick;
panel.AddChild(button);
parent.AddChild(button);
break;
}
case "tab":
string? tabClickName = (string?)el.Attribute("onclick");
Action? tabClick = BindAction(tabClickName, binding);
if (tabClickName is not null && tabClick is null)
{
throw new FormatException(
$"<tab onclick=\"{tabClickName}\"> did not resolve to an "
+ $"Action property on {binding.GetType().Name}");
}
var tab = new UiMarkupTabButton
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
Text = (string?)el.Attribute("text") ?? string.Empty,
DatFont = datFont,
SelectedSource = BindRequiredBoolReader(
(string?)el.Attribute("selected"),
binding,
"tab selected"),
};
ApplyCommon(tab, el, binding);
if (tabClick is not null)
tab.Click += tabClick;
parent.AddChild(tab);
break;
case "toggle":
string? toggleClickName = (string?)el.Attribute("onclick");
Action? toggleClick = BindAction(toggleClickName, binding);
if (toggleClickName is not null && toggleClick is null)
{
throw new FormatException(
$"<toggle onclick=\"{toggleClickName}\"> did not resolve to an "
+ $"Action property on {binding.GetType().Name}");
}
string? toggleCaption = (string?)el.Attribute("text");
var toggle = new UiMarkupToggle
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
Text = toggleCaption ?? string.Empty,
TextSource = BindString(toggleCaption, binding),
CheckedSource = BindRequiredBoolReader(
(string?)el.Attribute("checked"),
binding,
"toggle checked"),
DatFont = datFont,
Toggle = toggleClick,
};
if (el.Attribute("color") is not null)
toggle.TextColor = Color((string?)el.Attribute("color"));
ApplyCommon(toggle, el, binding);
parent.AddChild(toggle);
break;
case "slider":
string? changeName = (string?)el.Attribute("onchange");
Action<float>? changed = BindFloatAction(changeName, binding);
if (changeName is not null && changed is null)
{
throw new FormatException(
$"<slider onchange=\"{changeName}\"> did not resolve to an "
+ $"Action<float> property on {binding.GetType().Name}");
}
var slider = new UiScrollbar
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
Horizontal = true,
SpriteResolve = resolve,
ScalarPositionSource = BindFloat(
(string?)el.Attribute("value"),
binding),
ScalarChanged = changed,
};
RetailScrollbarChrome.ApplyHorizontal(slider);
ApplyCommon(slider, el, binding);
parent.AddChild(slider);
break;
case "field":
string? fieldChangeName = (string?)el.Attribute("onchange");
Action<string>? fieldChanged = BindStringAction(
fieldChangeName,
binding);
if (fieldChangeName is not null && fieldChanged is null)
{
throw new FormatException(
$"<field onchange=\"{fieldChangeName}\"> did not resolve to an "
+ $"Action<string> property on {binding.GetType().Name}");
}
string? submitName = (string?)el.Attribute("onsubmit");
Action<string>? submitted = BindStringAction(submitName, binding);
if (submitName is not null && submitted is null)
{
throw new FormatException(
$"<field onsubmit=\"{submitName}\"> did not resolve to an "
+ $"Action<string> property on {binding.GetType().Name}");
}
var field = new UiField
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
DatFont = datFont,
BackgroundColor = el.Attribute("background") is null
? new Vector4(0f, 0f, 0f, 0.9f)
: Color((string?)el.Attribute("background")),
TextColor = el.Attribute("color") is null
? new Vector4(0.91f, 0.87f, 0.76f, 1f)
: Color((string?)el.Attribute("color")),
MaxCharacters = Math.Max(1, I(el, "maxlength", 128)),
ClearOnSubmit = B(el, "clearonsubmit", false),
RecordHistory = false,
OnTextChanged = fieldChanged,
OnSubmit = submitted,
};
field.SetText(BindString((string?)el.Attribute("text"), binding)());
ApplyCommon(field, el, binding);
parent.AddChild(field);
break;
case "menu":
string? menuChangeName = (string?)el.Attribute("onchange");
Action<string>? menuChanged = BindStringAction(
menuChangeName,
binding);
if (menuChangeName is not null && menuChanged is null)
{
throw new FormatException(
$"<menu onchange=\"{menuChangeName}\"> did not resolve to an "
+ $"Action<string> property on {binding.GetType().Name}");
}
Func<IReadOnlyList<string>> menuItems = BindStringList(
(string?)el.Attribute("items"),
binding,
"menu items");
Func<string?> menuSelected = BindString(
(string?)el.Attribute("selected"),
binding);
var menu = new UiMenu
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
DatFont = datFont,
SpriteResolve = resolve,
RowsPerColumn = Math.Max(1, I(el, "rows", 7)),
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
ColumnWidth = Math.Max(20f, F(el, "w")),
OpenUpward = B(el, "openupward", false),
TextIndent = 6f,
ButtonTextIndent = 6f,
NormalSprite = 0x06004D65u,
PressedSprite = 0x06004D66u,
PopupBgSprite = 0x0600124Cu,
ItemNormalSprite = 0x0600124Eu,
ItemHighlightSprite = 0x0600124Du,
ButtonLabelProvider = () => menuSelected() ?? string.Empty,
OnSelect = payload =>
{
if (payload is string value)
menuChanged?.Invoke(value);
},
};
void RefreshMenu()
{
menu.Items = menuItems()
.Select(static value => new UiMenu.MenuItem(value, value))
.ToArray();
menu.Selected = menuSelected();
}
RefreshMenu();
menu.BeforeOpen = RefreshMenu;
ApplyCommon(menu, el, binding);
parent.AddChild(menu);
break;
case "list":
string? listChangeName = (string?)el.Attribute("onchange");
Action<int>? listChanged = BindIntAction(listChangeName, binding);
if (listChangeName is not null && listChanged is null)
{
throw new FormatException(
$"<list onchange=\"{listChangeName}\"> did not resolve to an "
+ $"Action<int> property on {binding.GetType().Name}");
}
var list = new UiMarkupList
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
DatFont = datFont,
ItemsSource = BindStringList(
(string?)el.Attribute("items"),
binding,
"list items"),
ItemColorsSource = BindUintList(
(string?)el.Attribute("colors"),
binding,
"list colors"),
SelectedIndexSource = BindRequiredIntReader(
(string?)el.Attribute("selected"),
binding,
"list selected"),
SelectionChanged = listChanged,
};
ApplyCommon(list, el, binding);
parent.AddChild(list);
break;
}
return panel;
}
/// <summary>
@ -197,13 +470,197 @@ public static class MarkupDocument
return () => (property.GetValue(binding) as Action)?.Invoke();
}
private static Action<float>? BindFloatAction(
string? attribute,
object binding)
{
if (attribute is null || !IsBinding(attribute))
return null;
string name = attribute[1..^1];
PropertyInfo? property = binding.GetType().GetProperty(name);
if (property is null
|| !typeof(Action<float>).IsAssignableFrom(property.PropertyType))
return null;
return value => (property.GetValue(binding) as Action<float>)?.Invoke(value);
}
private static Action<string>? BindStringAction(
string? attribute,
object binding)
{
if (attribute is null || !IsBinding(attribute))
return null;
string name = attribute[1..^1];
PropertyInfo? property = binding.GetType().GetProperty(name);
if (property is null
|| !typeof(Action<string>).IsAssignableFrom(property.PropertyType))
{
return null;
}
return value => (property.GetValue(binding) as Action<string>)?.Invoke(value);
}
private static Action<int>? BindIntAction(string? attribute, object binding)
{
if (attribute is null || !IsBinding(attribute))
return null;
PropertyInfo? property = binding.GetType().GetProperty(attribute[1..^1]);
if (property is null
|| !typeof(Action<int>).IsAssignableFrom(property.PropertyType))
{
return null;
}
return value => (property.GetValue(binding) as Action<int>)?.Invoke(value);
}
private static Func<IReadOnlyList<string>> BindStringList(
string? expression,
object binding,
string context)
{
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
throw new FormatException($"{context} must be a string-list binding");
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null
|| !typeof(IEnumerable<string>).IsAssignableFrom(property.PropertyType))
{
throw new FormatException(
$"{expression} did not resolve to an IEnumerable<string> property on "
+ binding.GetType().Name);
}
return () => property.GetValue(binding) is IEnumerable<string> values
? values.ToArray()
: Array.Empty<string>();
}
private static Func<IReadOnlyList<uint>> BindUintList(
string? expression,
object binding,
string context)
{
if (string.IsNullOrWhiteSpace(expression))
return static () => Array.Empty<uint>();
if (!IsBinding(expression))
throw new FormatException($"{context} must be a uint-list binding");
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null
|| !typeof(IEnumerable<uint>).IsAssignableFrom(property.PropertyType))
{
throw new FormatException(
$"{expression} did not resolve to an IEnumerable<uint> property on "
+ binding.GetType().Name);
}
return () => property.GetValue(binding) is IEnumerable<uint> values
? values.ToArray()
: Array.Empty<uint>();
}
private static bool IsBinding(string value) =>
value.Length > 2 && value[0] == '{' && value[^1] == '}';
private static void ApplyCommon(
UiElement element,
XElement source,
object binding)
{
element.Name = (string?)source.Attribute("name")
?? (string?)source.Attribute("id");
BindBool((string?)source.Attribute("visible"), binding,
value => element.Visible = value,
sourceReader => element.VisibleSource = sourceReader);
BindBool((string?)source.Attribute("enabled"), binding,
value => element.Enabled = value,
sourceReader => element.EnabledSource = sourceReader);
string? tooltip = (string?)source.Attribute("tooltip");
if (!string.IsNullOrWhiteSpace(tooltip))
{
element.RuntimeTooltipTextSource = BindString(tooltip, binding);
element.AuthoredTooltipRootElementId = RuntimeTooltipRootElementId;
element.AuthoredTooltipLayoutDid = RuntimeTooltipLayoutDid;
element.AuthoredTooltipEnabled = true;
}
}
private static void BindBool(
string? expression,
object binding,
Action<bool> setLiteral,
Action<Func<bool>> setSource)
{
if (string.IsNullOrWhiteSpace(expression))
return;
if (!IsBinding(expression))
{
if (bool.TryParse(expression, out bool literal))
setLiteral(literal);
return;
}
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null || property.PropertyType != typeof(bool))
{
throw new FormatException(
$"{expression} did not resolve to a bool property on "
+ binding.GetType().Name);
}
setSource(() => property.GetValue(binding) is true);
}
private static Func<bool> BindRequiredBoolReader(
string? expression,
object binding,
string context)
{
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
throw new FormatException($"{context} must be a bool binding");
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null || property.PropertyType != typeof(bool))
{
throw new FormatException(
$"{expression} did not resolve to a bool property on "
+ binding.GetType().Name);
}
return () => property.GetValue(binding) is true;
}
private static Func<int> BindRequiredIntReader(
string? expression,
object binding,
string context)
{
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
throw new FormatException($"{context} must be an int binding");
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null || property.PropertyType != typeof(int))
{
throw new FormatException(
$"{expression} did not resolve to an int property on "
+ binding.GetType().Name);
}
return () => property.GetValue(binding) is int value ? value : -1;
}
private static float F(XElement e, string attr)
=> float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
CultureInfo.InvariantCulture, out var v) ? v : 0f;
private static float FOr(XElement e, string attr, float fallback)
=> float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
CultureInfo.InvariantCulture, out float value) ? value : fallback;
private static int I(XElement e, string attr, int fallback)
=> int.TryParse((string?)e.Attribute(attr), NumberStyles.Integer,
CultureInfo.InvariantCulture, out int value) ? value : fallback;
private static bool B(XElement e, string attr, bool fallback)
=> bool.TryParse((string?)e.Attribute(attr), out bool value)
? value
: fallback;
/// <summary>
/// Parses <c>#AARRGGBB</c> → RGBA <see cref="Vector4"/> (alpha first, matching
/// controls.ini convention). Falls back to opaque white on bad input.

View file

@ -0,0 +1,355 @@
using System.Numerics;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.UI;
/// <summary>
/// Host-owned shelf for running gameplay plugins. A shelf button changes only
/// presentation visibility; it never touches plugin enable/session lifetime.
/// </summary>
public sealed class PluginSidePanel : UiPanel, IDisposable
{
private const float OuterPadding = 4f;
private const float ButtonExtent = 28f;
private const float ButtonGap = 4f;
private const float DefaultTop = 116f;
private readonly RetailWindowManager _windows;
private readonly Func<uint, (uint tex, int width, int height)> _resolve;
private readonly UiDatFont? _font;
private readonly Dictionary<RetailWindowHandle, ShelfEntry> _entries = [];
private bool _disposed;
private float _lastLayoutHeight = -1f;
public PluginSidePanel(
RetailWindowManager windows,
Func<uint, (uint tex, int width, int height)> resolve,
UiDatFont? font)
{
_windows = windows ?? throw new ArgumentNullException(nameof(windows));
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
_font = font;
Width = ButtonExtent + OuterPadding * 2f;
Height = OuterPadding * 2f;
Top = DefaultTop;
Anchors = AnchorEdges.None;
Draggable = false;
Resizable = false;
BackgroundColor = new Vector4(0f, 0f, 0f, 0.88f);
BorderColor = new Vector4(0.62f, 0.48f, 0.16f, 1f);
BorderThickness = 1f;
Visible = false;
_windows.WindowUnregistered += OnWindowUnregistered;
}
/// <summary>Number of live plugin-window entries, exposed for gates.</summary>
public int EntryCount => _entries.Count;
/// <summary>
/// Adds one manifest-scoped plugin window and its minimize affordance.
/// Duplicate handles are idempotent.
/// </summary>
public void Add(
PluginUiOwner owner,
PluginPanelDescriptor descriptor,
RetailWindowHandle handle)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id);
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(handle);
if (_entries.ContainsKey(handle))
return;
// Plugin windows are ordinary retained windows, but unlike imported
// retail windows they have no authored MoveTo override. Keep their
// chrome reachable at the minimum 800x600 canvas and after a display
// resize. An oversized window follows retail's top-left-priority rule:
// pin to zero rather than stranding the title/minimize controls.
handle.OuterFrame.ConstrainDragToParent = true;
handle.OuterFrame.ConstrainResizeToParent = true;
KeepWindowReachable(handle);
var button = new PluginShelfButton(
descriptor,
owner.DisplayName,
handle,
_resolve,
_font)
{
Width = ButtonExtent,
Height = ButtonExtent,
};
button.Click += () =>
{
if (handle.IsVisible)
handle.Hide();
else
handle.Show();
};
var minimize = new PluginMinimizeButton(handle, _font)
{
Left = MathF.Max(8f, handle.OuterFrame.Width - 23f),
Top = 3f,
Width = 18f,
Height = 17f,
Anchors = AnchorEdges.Top | AnchorEdges.Right,
};
handle.OuterFrame.AddChild(minimize);
_entries.Add(handle, new ShelfEntry(button, minimize));
AddChild(button);
Reflow();
}
protected override void OnTick(double deltaSeconds)
{
base.OnTick(deltaSeconds);
// Screen-edge dock: root bounds become authoritative at draw time, so
// compute this from the live parent rather than capturing an anchor
// margin while the pre-first-frame root still measures 0x0.
if (Parent is { } parent)
{
float availableHeight = MathF.Max(
ButtonExtent + OuterPadding * 2f,
parent.Height - Top - OuterPadding);
if (MathF.Abs(availableHeight - _lastLayoutHeight) > 0.5f)
{
_lastLayoutHeight = availableHeight;
Reflow(availableHeight);
}
Left = MathF.Max(0f, parent.Width - Width - OuterPadding);
}
foreach (RetailWindowHandle handle in _entries.Keys)
KeepWindowReachable(handle);
// The shelf remains reachable even after ordinary windows are raised.
if (Parent is { } root)
{
int highest = 0;
foreach (UiElement sibling in root.Children)
{
if (!ReferenceEquals(sibling, this))
highest = Math.Max(highest, sibling.ZOrder);
}
if (ZOrder <= highest)
ZOrder = highest == int.MaxValue ? highest : highest + 1;
}
}
private void OnWindowUnregistered(RetailWindowHandle handle)
{
if (!_entries.Remove(handle, out ShelfEntry entry))
return;
RemoveChild(entry.Button);
if (ReferenceEquals(entry.Minimize.Parent, handle.OuterFrame))
handle.OuterFrame.RemoveChild(entry.Minimize);
entry.Button.DisposeSubscriptions();
Reflow();
}
private static void KeepWindowReachable(RetailWindowHandle handle)
{
if (handle.OuterFrame.Parent is not { } parent
|| parent.Width <= 0f
|| parent.Height <= 0f)
{
return;
}
float left = Math.Clamp(
handle.Left,
0f,
MathF.Max(0f, parent.Width - handle.Width));
float top = Math.Clamp(
handle.Top,
0f,
MathF.Max(0f, parent.Height - handle.Height));
if (left != handle.Left || top != handle.Top)
handle.MoveTo(left, top);
}
private void Reflow(float maximumHeight = float.PositiveInfinity)
{
int maximumRows = float.IsPositiveInfinity(maximumHeight)
? Math.Max(1, _entries.Count)
: Math.Max(
1,
(int)MathF.Floor(
(maximumHeight - OuterPadding * 2f + ButtonGap)
/ (ButtonExtent + ButtonGap)));
int index = 0;
foreach (ShelfEntry entry in _entries.Values)
{
int column = index / maximumRows;
int row = index % maximumRows;
entry.Button.Left = OuterPadding
+ column * (ButtonExtent + ButtonGap);
entry.Button.Top = OuterPadding
+ row * (ButtonExtent + ButtonGap);
index++;
}
int rows = Math.Min(index, maximumRows);
int columns = index == 0 ? 1 : (index + maximumRows - 1) / maximumRows;
Width = OuterPadding * 2f
+ columns * ButtonExtent
+ Math.Max(0, columns - 1) * ButtonGap;
Height = OuterPadding * 2f
+ rows * ButtonExtent
+ Math.Max(0, rows - 1) * ButtonGap;
Visible = index > 0;
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_windows.WindowUnregistered -= OnWindowUnregistered;
foreach ((RetailWindowHandle handle, ShelfEntry entry) in _entries)
{
entry.Button.DisposeSubscriptions();
if (ReferenceEquals(entry.Minimize.Parent, handle.OuterFrame))
handle.OuterFrame.RemoveChild(entry.Minimize);
}
_entries.Clear();
Visible = false;
}
private readonly record struct ShelfEntry(
PluginShelfButton Button,
PluginMinimizeButton Minimize);
private sealed class PluginShelfButton : UiSimpleButton
{
private static readonly Vector4 HiddenBackground =
new(0.025f, 0.025f, 0.02f, 0.96f);
private static readonly Vector4 VisibleBackground =
new(0.09f, 0.19f, 0.055f, 0.96f);
private static readonly Vector4 HiddenBorder =
new(0.48f, 0.38f, 0.14f, 1f);
private static readonly Vector4 VisibleBorder =
new(0.76f, 0.64f, 0.25f, 1f);
private readonly RetailWindowHandle _handle;
private readonly Func<uint, (uint tex, int width, int height)> _resolve;
private readonly uint _iconSurfaceId;
private readonly string _tooltip;
internal PluginShelfButton(
PluginPanelDescriptor descriptor,
string ownerDisplayName,
RetailWindowHandle handle,
Func<uint, (uint tex, int width, int height)> resolve,
UiDatFont? font)
{
_handle = handle;
_resolve = resolve;
_iconSurfaceId = descriptor.IconSurfaceId;
_tooltip = string.Equals(descriptor.Title, ownerDisplayName,
StringComparison.Ordinal)
? descriptor.Title
: $"{ownerDisplayName} — {descriptor.Title}";
Text = _iconSurfaceId == 0
? Initials(descriptor.IconText, descriptor.Title)
: string.Empty;
DatFont = font;
Outline = true;
BorderThickness = 1f;
_handle.Shown += OnVisibilityChanged;
_handle.Hidden += OnVisibilityChanged;
RefreshPresentation();
}
public override string? GetTooltipText() => _tooltip;
protected override void OnTick(double deltaSeconds)
{
base.OnTick(deltaSeconds);
RefreshPresentation();
}
protected override void OnDraw(UiRenderContext ctx)
{
base.OnDraw(ctx);
if (_iconSurfaceId == 0)
return;
(uint texture, int width, int height) = _resolve(_iconSurfaceId);
if (texture == 0 || width <= 0 || height <= 0)
return;
float extent = MathF.Min(Width - 6f, Height - 6f);
ctx.DrawSprite(
texture,
(Width - extent) * 0.5f,
(Height - extent) * 0.5f,
extent,
extent,
0f,
0f,
1f,
1f,
Vector4.One);
}
internal void DisposeSubscriptions()
{
_handle.Shown -= OnVisibilityChanged;
_handle.Hidden -= OnVisibilityChanged;
}
private void OnVisibilityChanged(RetailWindowHandle _) =>
RefreshPresentation();
private void RefreshPresentation()
{
BackgroundColor = _handle.IsVisible
? VisibleBackground
: HiddenBackground;
BorderColor = _handle.IsVisible ? VisibleBorder : HiddenBorder;
}
private static string Initials(string? requested, string title)
{
if (!string.IsNullOrWhiteSpace(requested))
return requested.Trim()[..Math.Min(3, requested.Trim().Length)];
string[] words = title.Split(
' ',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (words.Length == 0)
return "?";
if (words.Length == 1)
return words[0][..Math.Min(2, words[0].Length)].ToUpperInvariant();
return string.Concat(words.Take(2).Select(static word =>
char.ToUpperInvariant(word[0])));
}
}
private sealed class PluginMinimizeButton : UiSimpleButton
{
private readonly RetailWindowHandle _handle;
internal PluginMinimizeButton(RetailWindowHandle handle, UiDatFont? font)
{
_handle = handle;
Text = "";
DatFont = font;
Outline = true;
BackgroundColor = new Vector4(0.02f, 0.02f, 0.015f, 0.94f);
BorderColor = new Vector4(0.58f, 0.46f, 0.17f, 1f);
BorderThickness = 1f;
Click += () => _handle.Hide();
}
public override string? GetTooltipText() => "Minimize to plugin sidepanel";
}
}

View file

@ -25,6 +25,7 @@ using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Panels.Vitals;
using AcDream.UI.Abstractions.Input;
using AcDream.Plugin.Abstractions;
using DatReaderWriter;
using Silk.NET.Input;
@ -531,7 +532,9 @@ public sealed record RetailUiRuntimeBindings(
CharacterSelectionRuntimeBindings? CharacterSelection = null,
// Campaign CC slice CC4: sibling of CharacterSelection above.
CharacterCreationRuntimeBindings? CharacterCreation = null,
Action? CaptureScreenshot = null);
Action? CaptureScreenshot = null,
Func<IReadOnlyList<PluginProjectileDebugSample>>?
ProjectileDebugSamples = null);
/// <summary>
/// Composition owner for the production retained gameplay UI. GameWindow supplies
@ -553,9 +556,11 @@ public sealed class RetailUiRuntime : IDisposable
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
private ItemCooldownUiController? _itemCooldownController;
private VividTargetIndicatorController? _vividTargetIndicator;
private ProjectileDebugOverlayController? _projectileDebugOverlay;
private Layout.VitalsSideBySideController? _vitalsSideBySide;
private CharacterManagementUiMountCoordinator? _characterManagementMount;
private CharacterCreationUiMountCoordinator? _characterCreationMount;
private PluginSidePanel? _pluginSidePanel;
private IDisposable? _characterSheetSubscription;
private Layout.CharacterTitlesController? _characterTitlesController;
private ResourceShutdownTransaction? _shutdown;
@ -602,6 +607,7 @@ public sealed class RetailUiRuntime : IDisposable
RetailUiRuntimeBindings bindings = _bindings;
MountFpsDisplay();
MountVividTargetIndicator();
MountProjectileDebugOverlay();
MountVitals();
MountRadar();
MountChat();
@ -935,6 +941,7 @@ public sealed class RetailUiRuntime : IDisposable
Layout.UiMediaClock.Advance(deltaSeconds);
FpsController?.Tick();
_vividTargetIndicator?.Tick();
_projectileDebugOverlay?.Tick();
_vitalsSideBySide?.Tick();
SpellbookWindowController?.Tick();
AppraisalController?.Tick(deltaSeconds);
@ -1655,6 +1662,18 @@ public sealed class RetailUiRuntime : IDisposable
: "[D.2b] vivid target indicator mounted from client-enum category 0x10000009.");
}
private void MountProjectileDebugOverlay()
{
if (_bindings.ProjectileDebugSamples is not { } samples)
return;
_projectileDebugOverlay = ProjectileDebugOverlayController.Mount(
Host.Root,
samples,
_bindings.VividTarget.Camera);
Console.WriteLine(
"[PluginUI] projectile collision debug overlay mounted.");
}
private void MountVitals()
{
ImportedLayout? layout = Import(0x2100006Cu);
@ -4608,16 +4627,64 @@ public sealed class RetailUiRuntime : IDisposable
{
try
{
string xml = File.ReadAllText(panel.MarkupPath);
UiElement element = MarkupDocument.Build(
string xml = panel.MarkupContent
?? File.ReadAllText(panel.MarkupPath);
UiNineSlicePanel element = MarkupDocument.Build(
xml,
panel.Binding,
_bindings.Assets.ResolveSprite,
_bindings.Assets.Controls,
_bindings.Assets.DefaultFont);
if (Host.WindowManager.TryGet(panel.WindowName, out _))
{
throw new InvalidOperationException(
$"Plugin window '{panel.WindowName}' is already registered. "
+ "Window ids must be unique within one plugin.");
}
// Markup's root visibility is an availability gate (for example,
// a world-only panel), while the descriptor/persisted state is the
// user's minimize choice. Keep those two axes independent so an
// availability transition never disables the running plugin or
// forgets that the user wanted its window open.
Func<bool>? availability = element.VisibleSource;
var visibility = new PluginWindowVisibilityController(
availability,
panel.Descriptor.StartVisible);
element.VisibleSource = visibility.ShouldBeVisible;
element.Visible = visibility.ShouldBeVisible();
Host.Root.AddChild(element);
// Publish ownership immediately after the tree mutation. Any
// later registration/sidepanel failure then rolls the mounted
// subtree back through FailMount instead of leaking it.
_bindings.Plugins.CompleteMount(panel, Host.Root, element);
Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}");
RetailWindowHandle handle = Host.WindowManager.Register(
panel.WindowName,
element,
element,
visibility);
_bindings.Plugins.CompleteWindowMount(
panel,
() => Host.WindowManager.Unregister(panel.WindowName));
if (panel.Descriptor.ShowInSidePanel)
{
if (_pluginSidePanel is null)
{
_pluginSidePanel = new PluginSidePanel(
Host.WindowManager,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont);
Host.Root.AddChild(_pluginSidePanel);
}
_pluginSidePanel.Add(panel.Owner, panel.Descriptor, handle);
}
Console.WriteLine(
$"[D.2b] plugin UI window loaded: {panel.WindowName} "
+ $"({panel.MarkupPath})");
}
catch (Exception ex)
{
@ -5312,6 +5379,7 @@ public sealed class RetailUiRuntime : IDisposable
{
_characterSheetSubscription?.Dispose();
_characterTitlesController?.Dispose();
_pluginSidePanel?.Dispose();
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
WindowLockPresentation.Dispose();
WindowOpacity.Dispose();
@ -5339,6 +5407,36 @@ public sealed class RetailUiRuntime : IDisposable
_disposed = _shutdown.IsComplete;
}
/// <summary>
/// Separates plugin availability from the user's minimized/open choice.
/// Window-manager callbacks update only the latter; a false availability
/// predicate hides temporarily without forgetting the requested state.
/// </summary>
private sealed class PluginWindowVisibilityController(
Func<bool>? availability,
bool startVisible) : IRetainedPanelController
{
private bool _requestedVisible = startVisible;
internal bool ShouldBeVisible() =>
_requestedVisible && (availability?.Invoke() ?? true);
public void OnShown() => _requestedVisible = true;
public void OnHidden()
{
// Hidden because the markup's availability gate went false is
// temporary. Hidden while available is a real minimize/restore-
// persistence transition and changes the requested state.
if (availability?.Invoke() ?? true)
_requestedVisible = false;
}
public void Dispose()
{
}
}
internal static ResourceShutdownTransaction CreateShutdownTransaction(
Action disposeAutomation,
Action disposePersistence,

View file

@ -55,7 +55,7 @@ public abstract class UiElement
public uint DatElementId { get; internal set; }
/// <summary>Human-readable name for debugging / FindByName.</summary>
public string? Name { get; init; }
public string? Name { get; set; }
/// <summary>
/// GF-13 (Campaign CC gate round 1, Batch A): mirrors
@ -274,6 +274,12 @@ public abstract class UiElement
/// </summary>
public Func<bool>? VisibleSource { get; set; }
/// <summary>
/// Optional live enabled reader. Declarative plugin controls use this to
/// expose unavailable/busy state without retaining presentation objects.
/// </summary>
public Func<bool>? EnabledSource { get; set; }
/// <summary>
/// If true, <see cref="UiRoot"/> will set focus here on click,
/// routing WM_KEYDOWN / WM_CHAR to <see cref="OnEvent"/> as
@ -642,7 +648,19 @@ public abstract class UiElement
/// hover delay (0.25 seconds by default), then queries the widget's virtual "GetString"
/// (vtable +0x88) to render the tooltip body.
/// </summary>
public virtual string? GetTooltipText() => null;
/// <remarks>
/// Runtime-created/plugin-markup widgets have no LayoutDesc property bag from
/// which to import P0x49. They use this live source instead; the markup host
/// still supplies retail's shared tooltip-popup locator, so presentation stays
/// inside the common retained tooltip pipeline rather than becoming plugin UI.
/// </remarks>
public Func<string?>? RuntimeTooltipTextSource { get; set; }
public virtual string? GetTooltipText()
{
string? text = RuntimeTooltipTextSource?.Invoke();
return string.IsNullOrWhiteSpace(text) ? null : text;
}
// ── Framework entry points (internal, called by UiRoot) ─────────────
@ -763,6 +781,8 @@ public abstract class UiElement
if (VisibleSource is { } visibility)
Visible = visibility();
if (!Visible) return;
if (EnabledSource is { } enabled)
Enabled = enabled();
OnTick(dt);
for (int i = 0; i < _children.Count; i++)
_children[i].TickSelfAndChildren(dt);

View file

@ -110,6 +110,13 @@ public sealed class UiField : UiElement
public Action<string>? OnSubmit { get; set; }
public Action? OnFocusGained { get; set; }
public Action<string>? OnFocusLost { get; set; }
/// <summary>
/// Live text mutation callback used by retained plugin markup. This is
/// deliberately separate from submit/focus-loss: editors need their
/// binding model to track typing so an adjacent button can consume the
/// current value without reaching into the widget tree.
/// </summary>
public Action<string>? OnTextChanged { get; set; }
private string _textValue = "";
@ -127,8 +134,11 @@ public sealed class UiField : UiElement
get => _textValue;
set
{
if (string.Equals(_textValue, value, StringComparison.Ordinal))
return;
_textValue = value;
_textVersion++;
OnTextChanged?.Invoke(value);
}
}

View file

@ -0,0 +1,96 @@
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Lightweight, data-bound string list for plugin markup. It owns only row
/// selection and scroll position; the plugin binding remains the sole owner of
/// rows and selected index. This deliberately avoids exposing App widget types
/// through the BCL plugin contract.
/// </summary>
public sealed class UiMarkupList : UiElement
{
public Func<IReadOnlyList<string>> ItemsSource { get; set; } =
static () => Array.Empty<string>();
public Func<IReadOnlyList<uint>> ItemColorsSource { get; set; } =
static () => Array.Empty<uint>();
public Func<int> SelectedIndexSource { get; set; } = static () => -1;
public Action<int>? SelectionChanged { get; set; }
public UiDatFont? DatFont { get; set; }
public float RowHeight { get; set; } = 18f;
public float Padding { get; set; } = 3f;
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.92f);
public Vector4 BorderColor { get; set; } = new(0.46f, 0.37f, 0.16f, 1f);
public Vector4 TextColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f);
public Vector4 SelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f);
private int _topRow;
public override bool HandlesClick => true;
protected override void OnDraw(UiRenderContext context)
{
IReadOnlyList<string> items = ItemsSource();
IReadOnlyList<uint> itemColors = ItemColorsSource();
int visibleRows = VisibleRows;
int selected = SelectedIndexSource();
if (selected >= 0 && selected < items.Count)
{
if (selected < _topRow)
_topRow = selected;
else if (selected >= _topRow + visibleRows)
_topRow = selected - visibleRows + 1;
}
ClampTop(items.Count, visibleRows);
context.DrawFill(0f, 0f, Width, Height, BackgroundColor);
context.DrawRectOutline(0f, 0f, Width, Height, BorderColor, 1f);
int end = Math.Min(items.Count, _topRow + visibleRows);
for (int index = _topRow; index < end; index++)
{
float y = (index - _topRow) * RowHeight;
if (index == selected)
context.DrawFill(1f, y + 1f, Width - 2f, RowHeight - 1f, SelectedColor);
string text = items[index];
Vector4 textColor = index < itemColors.Count
? Rgb(itemColors[index])
: TextColor;
float textY = y + MathF.Max(0f,
(RowHeight - (DatFont?.LineHeight ?? 14f)) * 0.5f);
if (DatFont is { } font)
context.DrawStringDat(font, text, Padding, textY, textColor, true);
else
context.DrawString(text, Padding, textY, textColor);
}
}
public override bool OnEvent(in UiEvent e)
{
IReadOnlyList<string> items = ItemsSource();
if (e.Type == UiEventType.Scroll)
{
_topRow -= Math.Sign(e.Data0);
ClampTop(items.Count, VisibleRows);
return true;
}
if (e.Type != UiEventType.MouseDown || !Enabled)
return false;
int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight));
int index = _topRow + row;
if (row >= 0 && row < VisibleRows && index >= 0 && index < items.Count)
SelectionChanged?.Invoke(index);
return true;
}
private int VisibleRows => Math.Max(1, (int)MathF.Floor(
Height / MathF.Max(1f, RowHeight)));
private void ClampTop(int count, int visibleRows) =>
_topRow = Math.Clamp(_topRow, 0, Math.Max(0, count - visibleRows));
private static Vector4 Rgb(uint value) => new(
((value >> 16) & 0xFFu) / 255f,
((value >> 8) & 0xFFu) / 255f,
(value & 0xFFu) / 255f,
1f);
}

View file

@ -0,0 +1,47 @@
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Compact KSML tab used by plugin windows. It deliberately uses the retained
/// input/font path and VTank's text-strip presentation rather than introducing
/// a plugin-owned renderer.
/// </summary>
public sealed class UiMarkupTabButton : UiSimpleButton
{
private static readonly Vector4 ActiveText =
new(0.94f, 0.76f, 0.18f, 1f);
private static readonly Vector4 NormalText =
new(0.78f, 0.76f, 0.67f, 1f);
private static readonly Vector4 DisabledText =
new(0.34f, 0.33f, 0.29f, 1f);
private static readonly Vector4 Underline =
new(0.77f, 0.59f, 0.12f, 1f);
public Func<bool>? SelectedSource { get; set; }
public bool IsSelected => SelectedSource?.Invoke() ?? false;
public UiMarkupTabButton()
{
BackgroundColor = Vector4.Zero;
BorderColor = Vector4.Zero;
BorderThickness = 0f;
Outline = true;
}
protected override void OnTick(double deltaSeconds)
{
base.OnTick(deltaSeconds);
TextColor = !Enabled
? DisabledText
: IsSelected ? ActiveText : NormalText;
}
protected override void OnDraw(UiRenderContext ctx)
{
base.OnDraw(ctx);
if (IsSelected)
ctx.DrawFill(2f, Height - 2f, MathF.Max(0f, Width - 4f), 1f, Underline);
}
}

View file

@ -0,0 +1,75 @@
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// KSML boolean toggle with the compact lamp-and-caption presentation used by
/// VTank. State and action remain reflected BCL bindings owned by the plugin.
/// </summary>
public sealed class UiMarkupToggle : UiElement
{
private static readonly Vector4 CheckedOuter =
new(0.36f, 0.58f, 0.12f, 1f);
private static readonly Vector4 CheckedInner =
new(0.52f, 1f, 0.08f, 1f);
private static readonly Vector4 UncheckedOuter =
new(0.26f, 0.22f, 0.13f, 1f);
private static readonly Vector4 UncheckedInner =
new(0.38f, 0.34f, 0.23f, 1f);
public string Text { get; set; } = string.Empty;
public Func<string?>? TextSource { get; set; }
public Func<bool>? CheckedSource { get; set; }
public UiDatFont? DatFont { get; set; }
public Vector4 TextColor { get; set; } =
new(0.86f, 0.84f, 0.74f, 1f);
public Action? Toggle { get; set; }
public bool IsChecked => CheckedSource?.Invoke() ?? false;
public override bool HandlesClick => true;
public override bool OnEvent(in UiEvent e)
{
if (e.Type != UiEventType.Click || !Enabled)
return false;
Toggle?.Invoke();
return true;
}
protected override void OnDraw(UiRenderContext ctx)
{
Vector4 outer = IsChecked ? CheckedOuter : UncheckedOuter;
Vector4 inner = IsChecked ? CheckedInner : UncheckedInner;
DrawLamp(ctx, 1f, MathF.Max(1f, (Height - 11f) * 0.5f), outer, inner);
string caption = TextSource?.Invoke() ?? Text;
Vector4 color = Enabled
? TextColor
: new Vector4(TextColor.X, TextColor.Y, TextColor.Z, 0.42f);
float y = DatFont is { } font
? (Height - font.LineHeight) * 0.5f
: 1f;
if (DatFont is { } dat)
ctx.DrawStringDat(dat, caption, 17f, y, color, outline: true);
else
ctx.DrawString(caption, 17f, y, color);
}
private static void DrawLamp(
UiRenderContext ctx,
float x,
float y,
Vector4 outer,
Vector4 inner)
{
// Five bands form the small circular indicator without introducing a
// plugin bitmap or a new renderer primitive.
ctx.DrawFill(x + 3f, y, 5f, 1f, outer);
ctx.DrawFill(x + 1f, y + 1f, 9f, 2f, outer);
ctx.DrawFill(x, y + 3f, 11f, 5f, outer);
ctx.DrawFill(x + 1f, y + 8f, 9f, 2f, outer);
ctx.DrawFill(x + 3f, y + 10f, 5f, 1f, outer);
ctx.DrawFill(x + 3f, y + 3f, 5f, 5f, inner);
}
}

View file

@ -74,7 +74,9 @@ public sealed class UiMenu : UiElement
string? live = TooltipTextProvider?.Invoke();
if (!string.IsNullOrWhiteSpace(live))
return live;
return string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
return string.IsNullOrWhiteSpace(TooltipText)
? base.GetTooltipText()
: TooltipText;
}
public int RowsPerColumn { get; set; } = 7; // items per column (dat item template);

View file

@ -37,6 +37,12 @@ public sealed class UiScrollbar : UiElement
/// </summary>
public float ScalarPosition { get; private set; }
public Action<float>? ScalarChanged { get; set; }
/// <summary>
/// Optional live scalar reader used by plugin markup. It is sampled while
/// no thumb gesture is active so external/profile changes reach the widget
/// without fighting the value under the user's cursor.
/// </summary>
public Func<float?>? ScalarPositionSource { get; set; }
public bool Horizontal { get; set; }
/// <summary>True while a thumb drag is in progress (between a thumb-hit
@ -94,6 +100,13 @@ public sealed class UiScrollbar : UiElement
public void SetScalarPosition(float position)
=> ScalarPosition = Math.Clamp(position, 0f, 1f);
protected override void OnTick(double deltaSeconds)
{
base.OnTick(deltaSeconds);
if (!_draggingThumb && ScalarPositionSource?.Invoke() is { } value)
SetScalarPosition(value);
}
/// <summary>Settable tooltip, surfaced through the shared
/// <see cref="UiElement.GetTooltipText"/> hover pipeline — the SAME
/// pattern <see cref="UiButton.TooltipText"/> already established
@ -105,7 +118,9 @@ public sealed class UiScrollbar : UiElement
/// <inheritdoc />
public override string? GetTooltipText() =>
string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
string.IsNullOrWhiteSpace(TooltipText)
? base.GetTooltipText()
: TooltipText;
/// <summary>RenderSurface id → (GL tex, w, h). 0 id = skip.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }