fix(client): restore retail interaction parity
All checks were successful
CI / linux-portable (push) Successful in 3m27s
CI / windows-gate (push) Successful in 6m42s
CI / release (push) Successful in 2m12s

Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
Erik 2026-08-26 20:45:11 +02:00
parent 0c699240e0
commit f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions

View file

@ -62,10 +62,10 @@ internal sealed class AutoWieldController : IDisposable
private readonly Func<uint> _playerGuid;
private readonly Action<uint, uint>? _sendWield;
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
private readonly Action<string>? _toast;
private readonly Action<string>? _systemMessage;
private readonly CombatState? _combatState;
private readonly Action<CombatMode>? _sendChangeCombatMode;
private readonly InventoryTransactionState? _transactions;
private PendingSwitch? _pendingSwitch;
private PendingCombatSettlement? _pendingCombatSettlement;
@ -79,19 +79,19 @@ internal sealed class AutoWieldController : IDisposable
Func<uint> playerGuid,
Action<uint, uint>? sendWield,
Action<uint, uint, int>? sendPutItemInContainer,
Action<string>? toast,
Action<string>? systemMessage = null,
CombatState? combatState = null,
Action<CombatMode>? sendChangeCombatMode = null)
Action<CombatMode>? sendChangeCombatMode = null,
InventoryTransactionState? transactions = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_sendWield = sendWield;
_sendPutItemInContainer = sendPutItemInContainer;
_toast = toast;
_systemMessage = systemMessage;
_combatState = combatState;
_sendChangeCombatMode = sendChangeCombatMode;
_transactions = transactions;
_objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemoved += OnObjectRemoved;
@ -238,8 +238,20 @@ internal sealed class AutoWieldController : IDisposable
: BestAvailableEquipMask(item);
if (mask == EquipMask.None)
{
_toast?.Invoke("That slot is already in use");
return false;
// UsingItem calls retail AutoWield with its automatic-unblock flag.
// When every compatible slot is occupied, retail chooses the first
// compatible slot, moves that blocker to the backpack, and retries
// only after RecvNotice_ServerSaysMoveItem confirms the move.
// CPlayerSystem::AutoWield @ 0x0056173D-0x0056186E.
mask = FirstCompatibleEquipMask(item);
ClientObject? blocker = GetEquippedObjectAtLocation(
mask, priority: 0, item.ObjectId);
return blocker is not null
&& BeginWeaponReplacement(
item.ObjectId,
blocker,
mask,
combatModeAfterWield: null);
}
return SendWield(item, mask, combatModeAfterWield: null);
@ -252,10 +264,7 @@ internal sealed class AutoWieldController : IDisposable
CombatMode? combatModeAfterWield)
{
if (_sendPutItemInContainer is null)
{
_toast?.Invoke("That slot is already in use");
return false;
}
uint player = _playerGuid();
if (player == 0)
@ -272,8 +281,17 @@ internal sealed class AutoWieldController : IDisposable
// is the transaction boundary and preserves its stance-specific motion.
_systemMessage?.Invoke(
$"Moving {blockingItem.GetAppropriateName()} to your backpack");
_sendPutItemInContainer(blockingItem.ObjectId, player, 0);
return true;
bool dispatched = DispatchInventoryRequest(
InventoryRequestKind.PutInContainer,
blockingItem.ObjectId,
() =>
{
_sendPutItemInContainer(blockingItem.ObjectId, player, 0);
return true;
});
if (!dispatched)
_pendingSwitch = null;
return dispatched;
}
private bool SendWield(
@ -288,17 +306,26 @@ internal sealed class AutoWieldController : IDisposable
BlockingItemId: 0,
RequestedMask: mask,
CombatModeAfterWield: combatModeAfterWield);
if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask))
{
_pendingSwitch = null;
return false;
}
// Retail ACCWeenieObject::UIAttemptWield @ 0x0058D590.
_sendWield(item.ObjectId, (uint)mask);
return true;
bool dispatched = DispatchInventoryRequest(
InventoryRequestKind.Wield,
item.ObjectId,
() =>
{
_sendWield(item.ObjectId, (uint)mask);
return true;
});
if (!dispatched)
_pendingSwitch = null;
return dispatched;
}
private bool DispatchInventoryRequest(
InventoryRequestKind kind,
uint itemId,
Func<bool> dispatch)
=> _transactions?.TryDispatch(kind, itemId, dispatch) ?? dispatch();
private void OnObjectMoved(ClientObjectMove move)
{
if (_pendingSwitch is not { } pending
@ -454,6 +481,14 @@ internal sealed class AutoWieldController : IDisposable
return EquipMask.None;
}
private static EquipMask FirstCompatibleEquipMask(ClientObject item)
{
foreach (EquipMask mask in AutoEquipOrder)
if ((item.ValidLocations & mask) != EquipMask.None)
return mask;
return EquipMask.None;
}
private bool AutoWearIsLegal(
ClientObject item,
out ClientObject? blocker)

View file

@ -43,6 +43,7 @@ public sealed class ItemInteractionController : IDisposable
private readonly Action<uint, uint>? _sendSplitToWorld;
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
private readonly Action<uint, uint, uint, uint>? _sendSplitToContainer;
private readonly Action<uint, uint, uint>? _sendStackableMerge;
private readonly Action<uint, uint, uint>? _sendGive;
private readonly Action<string>? _toast;
private readonly Func<bool> _readyForInventoryRequest;
@ -118,7 +119,8 @@ public sealed class ItemInteractionController : IDisposable
Func<uint, uint, int, uint, bool>? sendBuy = null,
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<string, RetailLogTextType>? interfaceText = null,
Action<uint, uint, uint>? sendStackableMerge = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -130,6 +132,7 @@ public sealed class ItemInteractionController : IDisposable
_sendSplitToWorld = sendSplitToWorld;
_sendPutItemInContainer = sendPutItemInContainer;
_sendSplitToContainer = sendSplitToContainer;
_sendStackableMerge = sendStackableMerge;
_sendGive = sendGive;
_nowMs = nowMs ?? (() => Environment.TickCount64);
_toast = toast;
@ -168,10 +171,10 @@ public sealed class ItemInteractionController : IDisposable
_playerGuid,
_sendWield,
sendPutItemInContainer,
_toast,
_systemMessage,
combatState,
sendChangeCombatMode);
sendChangeCombatMode,
_transactions);
_interactionState.Changed += OnInteractionModeChanged;
_transactions.StateChanged += OnTransactionStateChanged;
_transactions.RequestCompleted += OnInventoryRequestCompleted;
@ -182,6 +185,12 @@ public sealed class ItemInteractionController : IDisposable
public event Action? StateChanged;
/// <summary>
/// Retail <c>ItemHolder::AttemptMerge</c> immediately selects the target
/// stack and publishes the toolbar merge-attempt notice after dispatch.
/// </summary>
public event Action<uint, uint>? MergeAttempted;
/// <summary>
/// Retail's two secure-trade open paths surface here for the trade UI:
/// (partnerGuid, itemGuid) — itemGuid 0 for Use-on-player
@ -457,6 +466,40 @@ public sealed class ItemInteractionController : IDisposable
public bool TryGetPendingInventoryRequest(out PendingInventoryRequest pending)
=> _transactions.TryGetPending(out pending);
/// <summary>
/// Retail <c>ACCWeenieObject::UIAttemptSplitToContainer</c>: split an
/// exact partial quantity into a container through the canonical
/// one-request inventory gate. The source remains in place until the
/// authoritative stack update and newly-created split object arrive.
/// </summary>
public bool TrySplitToContainer(
uint itemId,
uint containerId,
uint placement,
uint amount)
{
if (itemId == 0u
|| containerId == 0u
|| _sendSplitToContainer is null
|| _objects.Get(itemId) is not { } item)
{
return false;
}
uint fullStack = (uint)Math.Max(1, item.StackSize);
if (amount == 0u || amount >= fullStack)
return false;
return TryDispatchInventoryRequest(
InventoryRequestKind.SplitToContainer,
itemId,
() =>
{
_sendSplitToContainer(itemId, containerId, placement, amount);
return true;
});
}
/// <summary>
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a
/// request issued by another retained controller has been sent. The
@ -486,6 +529,29 @@ public sealed class ItemInteractionController : IDisposable
public bool IsPendingSource(uint itemGuid)
=> itemGuid != 0 && itemGuid == PendingSourceItem;
/// <summary>
/// True while retail's global inventory request latch owns this physical
/// item. Retained item lists use it for the waiting/ghosted source visual;
/// canonical placement remains unchanged until the server response.
/// </summary>
public bool IsPendingInventorySource(uint itemGuid)
=> itemGuid != 0
&& _transactions.TryGetPending(out PendingInventoryRequest pending)
&& pending.ItemId == itemGuid;
/// <summary>Route a literal local refusal to retail's SpewBox channel.</summary>
public void ReportClientLocal(string message)
{
if (string.IsNullOrWhiteSpace(message))
return;
if (_interfaceText is not null)
_interfaceText(message, RetailLogTextType.ClientLocal);
else if (_systemMessage is not null)
_systemMessage(message);
else
_toast?.Invoke(message);
}
/// <summary>
/// Retail <c>ACCWeenieObject::IsOwnedByPlayer</c> projection shared with
/// toolbar shortcut creation.
@ -689,15 +755,46 @@ public sealed class ItemInteractionController : IDisposable
/// publishes the waiting destination slot before issuing the move request,
/// exactly like double-click pickup through ItemHolder.
/// </summary>
public bool PlaceWorldItemInBackpack(uint itemGuid)
public bool PlaceWorldItemInBackpack(uint itemGuid, bool mainPack = false)
{
if (itemGuid == 0u || _placeInBackpack is null)
return false;
uint containerId = _backpackContainerId();
uint containerId = mainPack ? _playerGuid() : _backpackContainerId();
if (containerId == 0u)
containerId = _playerGuid();
const int placement = 0;
// CPlayerSystem::PlaceInBackpack passes autoMerge=true to
// ItemHolder::AttemptToPlaceInContainer. Retail searches the player's
// exhaustive carried inventory first and only merges when one target
// can accept the complete selected split quantity.
if (TryPlanAutoMerge(itemGuid) is { } merge)
{
if (!TryDispatchPendingBackpackPlacement(
itemGuid,
containerId,
placement,
InventoryRequestKind.Merge,
() =>
{
_sendStackableMerge!(
merge.SourceObjectId,
merge.TargetObjectId,
merge.Amount);
MergeAttempted?.Invoke(
merge.SourceObjectId,
merge.TargetObjectId);
return true;
}))
{
// As with ordinary pickup, retail consumes the key while the
// shared inventory-request gate is busy.
return true;
}
return true;
}
if (!TryBeginPendingBackpackPlacement(
itemGuid,
containerId,
@ -716,6 +813,67 @@ public sealed class ItemInteractionController : IDisposable
return true;
}
private StackMergePlan? TryPlanAutoMerge(uint sourceId)
{
if (_sendStackableMerge is null
|| _objects.Get(sourceId) is not { } source
|| source.StackSizeMax <= 1)
{
return null;
}
uint requested = _stackSplitQuantity?.GetObjectSplitSize(
sourceId,
_selectedObjectId(),
(uint)Math.Max(1, source.StackSize))
?? (uint)Math.Max(1, source.StackSize);
int requestedAmount = (int)Math.Min(requested, int.MaxValue);
var sourceMerge = ToStackMergeItem(source);
uint player = _playerGuid();
if (player == 0u)
return null;
var visitedContainers = new HashSet<uint>();
foreach (uint targetId in ExhaustiveContents(player, visitedContainers))
{
if (_objects.Get(targetId) is not { } target)
continue;
StackMergePlan? plan = StackMergePlanner.Plan(
sourceMerge,
ToStackMergeItem(target),
CanMakeInventoryRequest,
requestedAmount);
// AttemptAutoMerge rejects a partial fit and keeps searching.
if (plan is { } complete && complete.Amount == requested)
return complete;
}
return null;
}
private IEnumerable<uint> ExhaustiveContents(
uint containerId,
HashSet<uint> visitedContainers)
{
if (!visitedContainers.Add(containerId))
yield break;
foreach (uint itemId in _objects.GetContents(containerId))
{
yield return itemId;
if (_objects.GetContents(itemId).Count == 0)
continue;
foreach (uint nested in ExhaustiveContents(itemId, visitedContainers))
yield return nested;
}
}
private static StackMergeItem ToStackMergeItem(ClientObject item) => new(
item.ObjectId,
item.WeenieClassId,
item.StackSize,
item.StackSizeMax,
item.TradeState);
public bool TryBeginPendingBackpackPlacement(
uint itemGuid,
uint containerId,
@ -1043,6 +1201,14 @@ public sealed class ItemInteractionController : IDisposable
public bool DropToWorld(ItemDragPayload payload)
=> PlaceIn3D(payload, targetGuid: 0u);
/// <summary>
/// Keyboard equivalent of dropping the selected inventory item into the
/// 3-D view. Retail routes Give Selected and Drop Selected through the
/// same <c>ItemHolder::AttemptPlaceIn3D @ 0x00588600</c> policy as a drag.
/// </summary>
public bool PlaceSelectedIn3D(uint itemGuid, uint targetGuid)
=> PlaceIn3D(itemGuid, ItemDragSource.Inventory, targetGuid);
/// <summary>
/// Retail inventory drag released into SmartBox. The release target is the
/// world object under the cursor, or zero for empty ground. This is the live
@ -1052,9 +1218,17 @@ public sealed class ItemInteractionController : IDisposable
{
ArgumentNullException.ThrowIfNull(payload);
if (payload.SourceKind == ItemDragSource.ShortcutBar)
return PlaceIn3D(payload.ObjId, payload.SourceKind, targetGuid);
}
private bool PlaceIn3D(
uint itemGuid,
ItemDragSource sourceKind,
uint targetGuid)
{
if (sourceKind == ItemDragSource.ShortcutBar)
return false;
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item)
if (itemGuid == 0 || _objects.Get(itemGuid) is not { } item)
return false;
if (!EnsureInventoryRequestReady())
return false;
@ -1154,7 +1328,7 @@ public sealed class ItemInteractionController : IDisposable
break;
case ItemPolicyActionKind.Reject:
if (!string.IsNullOrWhiteSpace(action.Message))
_toast?.Invoke(action.Message);
ReportClientLocal(action.Message);
break;
case ItemPolicyActionKind.OpenSecureTrade:
// Use-on-player (ItemHolder::DetermineUseResult
@ -1169,8 +1343,9 @@ public sealed class ItemInteractionController : IDisposable
PolicyActionRequested?.Invoke(action);
bool handled = _auxiliaryAction is not null || PolicyActionRequested is not null;
if (!handled)
_toast?.Invoke(PolicyActionMessage(action));
acted |= handled || _toast is not null;
ReportClientLocal(PolicyActionMessage(action));
acted |= handled || _interfaceText is not null
|| _systemMessage is not null || _toast is not null;
break;
}
}
@ -1199,14 +1374,8 @@ public sealed class ItemInteractionController : IDisposable
action.ObjectId,
() =>
{
if (_sendDrop is null
|| !_objects.MoveItemOptimistic(
action.ObjectId,
newContainerId: 0u,
newSlot: -1))
{
if (_sendDrop is null)
return false;
}
_sendDrop(action.ObjectId);
return true;
});
@ -1290,13 +1459,13 @@ public sealed class ItemInteractionController : IDisposable
}
case ItemPolicyActionKind.Reject:
if (!string.IsNullOrWhiteSpace(action.Message))
_toast?.Invoke(action.Message);
ReportClientLocal(action.Message);
break;
default:
_auxiliaryAction?.Invoke(action);
PolicyActionRequested?.Invoke(action);
if (_auxiliaryAction is null && PolicyActionRequested is null)
_toast?.Invoke(PolicyActionMessage(action));
ReportClientLocal(PolicyActionMessage(action));
break;
}
}
@ -1308,7 +1477,7 @@ public sealed class ItemInteractionController : IDisposable
_interactionState.EnterUseItemOnTarget(sourceGuid);
var name = _objects.Get(sourceGuid)?.Name;
if (!string.IsNullOrWhiteSpace(name))
_toast?.Invoke($"Choose a target for the {name}");
ReportClientLocal($"Choose a target for the {name}");
}
private void ClearTargetMode()
@ -1387,8 +1556,6 @@ public sealed class ItemInteractionController : IDisposable
PendingInventoryRequest request,
uint weenieError)
{
if (_interfaceText is null)
return;
ClientObject? item = request.ItemIdentity ?? _objects.Get(request.ItemId);
if (item is null)
return;
@ -1407,7 +1574,7 @@ public sealed class ItemInteractionController : IDisposable
if (InventoryFailureMessages.Compose(request.Kind, name, weenieError)
is { } text)
{
_interfaceText(text, RetailLogTextType.ClientLocal);
ReportClientLocal(text);
}
}
@ -1443,6 +1610,7 @@ public sealed class ItemInteractionController : IDisposable
_transactions.RequestCompleted -= OnInventoryRequestCompleted;
_transactions.StateChanged -= OnTransactionStateChanged;
WorldDropDispatched = null;
MergeAttempted = null;
_autoWield.Dispose();
}
@ -1575,7 +1743,8 @@ public sealed class ItemInteractionController : IDisposable
stackSize,
stackSize,
IsIn3DView: item.ContainerId == 0 && item.WielderId == 0
&& item.ObjectId != _playerGuid());
&& item.ObjectId != _playerGuid(),
Name: item.GetAppropriateName());
}
/// <summary>

View file

@ -255,13 +255,23 @@ public static class CharacterStatController
// RetailScrollbarChrome (2026-08-24: the previous local constants seated
// the DOWN-arrow art on the top button).
private enum CharacterStatTab
public enum CharacterStatTab
{
Attributes,
Skills,
Titles,
}
/// <summary>
/// Live character-panel binding. Keyboard panel actions use the same
/// tab switch function as the authored tab buttons, so F8/F9 cannot
/// diverge from click behavior.
/// </summary>
public sealed record Binding(
Action Refresh,
Action<CharacterStatTab> ShowTab,
Func<CharacterStatTab> CurrentTab);
public enum RaiseTargetKind
{
Attribute,
@ -386,7 +396,7 @@ public static class CharacterStatController
/// next click. The caller invokes this from the sheet-changed
/// subscription.
/// </returns>
public static Action Bind(
public static Binding Bind(
ImportedLayout layout,
Func<CharacterSheet> data,
UiDatFont? datFont = null,
@ -881,7 +891,10 @@ public static class CharacterStatController
// luminance-award quality change.
}
return () => RefreshAfterRaise(null);
return new Binding(
() => RefreshAfterRaise(null),
SwitchTab,
() => activeTab[0]);
}
private static UiScrollbar? PrepareSkillScrollbar(

View file

@ -100,9 +100,10 @@ internal static class ChatTranscriptRenderer
/// accumulating, so the two-threshold hysteresis has nothing to damp — it
/// exists to stop retail trimming on every single append. A single cap
/// gives a STABLE window here; oscillating one would make the oldest
/// visible line jump around as messages arrive. Cutting at whole lines is
/// automatic for the same reason: our unit already is the line, which is
/// what retail's newline preference is trying to achieve.
/// visible line jump around as messages arrive. Most entries are already
/// one line; an oversized server entry with embedded newlines is clipped
/// at the first complete line inside the retained suffix, matching
/// retail's newline preference.
/// </para>
/// </remarks>
public const int MaxTranscriptCharacters = 0x2710;
@ -119,6 +120,14 @@ internal static class ChatTranscriptRenderer
IReadOnlyList<FormattedLine> detailed,
Func<uint, bool>? accept,
int budget = MaxTranscriptCharacters)
=> FindBudgetStart(detailed, accept, budget).LineIndex;
private readonly record struct BudgetStart(int LineIndex, int CharacterOffset);
private static BudgetStart FindBudgetStart(
IReadOnlyList<FormattedLine> detailed,
Func<uint, bool>? accept,
int budget = MaxTranscriptCharacters)
{
long used = 0;
for (int i = detailed.Count - 1; i >= 0; i--)
@ -127,11 +136,68 @@ internal static class ChatTranscriptRenderer
continue;
// +1 for the newline retail stores between lines.
used += detailed[i].Text.Length + 1;
if (used > budget)
return i + 1;
long cost = detailed[i].Text.Length + 1L;
if (used + cost <= budget)
{
used += cost;
continue;
}
int available = (int)Math.Max(0L, budget - used - 1L);
if (available > 0)
{
string text = detailed[i].Text;
int minimumOffset = Math.Max(0, text.Length - available);
int offset = FirstCharacterAfterLineBreak(text, minimumOffset);
if (offset < text.Length)
return new BudgetStart(i, offset);
// A single newest unbroken message must still remain visible;
// dropping it wholesale is what made large @acecommands
// replies render as an empty transcript.
if (used == 0 && text.Length > 0)
return new BudgetStart(i, minimumOffset);
}
return new BudgetStart(i + 1, 0);
}
return 0;
return new BudgetStart(0, 0);
}
private static int FirstCharacterAfterLineBreak(string text, int start)
{
for (int i = Math.Clamp(start, 0, text.Length); i < text.Length; i++)
{
if (text[i] is not ('\r' or '\n'))
continue;
if (text[i] == '\r' && i + 1 < text.Length && text[i + 1] == '\n')
i++;
return i + 1;
}
return text.Length;
}
private static FormattedLine SliceLine(FormattedLine line, int offset)
{
if (offset <= 0)
return line;
string text = line.Text[offset..];
if (line.Spans is not { Count: > 0 } spans)
return line with { Text = text };
var sliced = new List<ChatTextSpan>();
int at = 0;
foreach (ChatTextSpan span in spans)
{
int end = at + span.Text.Length;
if (end > offset)
{
int from = Math.Max(offset, at) - at;
sliced.Add(span with { Text = span.Text[from..] });
}
at = end;
}
return line with { Text = text, Spans = sliced };
}
/// <summary>
@ -257,12 +323,14 @@ internal static class ChatTranscriptRenderer
// (defaultColor), matching retail's DoFontReset — not the color table's
// unrelated index-0x00 slot.
Vector4 currentColor = defaultColor;
int firstLine = FirstLineWithinBudget(detailed, accept);
for (int lineIndex = firstLine; lineIndex < detailed.Count; lineIndex++)
BudgetStart start = FindBudgetStart(detailed, accept);
for (int lineIndex = start.LineIndex; lineIndex < detailed.Count; lineIndex++)
{
FormattedLine d = detailed[lineIndex];
if (accept is not null && !accept(d.LogTextType))
continue;
if (lineIndex == start.LineIndex && start.CharacterOffset > 0)
d = SliceLine(d, start.CharacterOffset);
if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved))
currentColor = resolved;
// Wrapping can DROP the space it broke on, so a fragment is not

View file

@ -5,6 +5,7 @@ using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.UI.Layout;
@ -1029,6 +1030,44 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
FindRootOf(Input)?.SetKeyboardFocus(Input);
}
/// <summary>
/// Retail <c>EnterChatMode</c>: enter write mode and select the complete
/// existing entry so the next typed character replaces it.
/// </summary>
internal void EnterChatMode(KeyChord? physicalChord = null)
{
UiRoot? root = FindRootOf(Input);
root?.SetKeyboardFocus(Input);
if (physicalChord is { Device: 0 } chord)
root?.SuppressPhysicalKeyUntilRelease(chord.Key);
Input.SelectAllText();
}
/// <summary>Retail <c>ToggleChatEntry</c>: toggle write-mode focus.</summary>
internal void ToggleChatEntry(KeyChord? physicalChord = null)
{
UiRoot? root = FindRootOf(Input);
if (root is null)
return;
root.SetKeyboardFocus(ReferenceEquals(root.KeyboardFocus, Input) ? null : Input);
if (physicalChord is { Device: 0 } chord)
root.SuppressPhysicalKeyUntilRelease(chord.Key);
}
/// <summary>Retail command/alias hotkey: begin an ordinary slash command.</summary>
internal void StartCommand()
{
Input.SetText("/");
FindRootOf(Input)?.SetKeyboardFocus(Input);
}
/// <summary>Retail reply keys are silent when their independent target is empty.</summary>
internal void StartReply(string? name)
{
if (!string.IsNullOrEmpty(name))
StartTell(name);
}
private static UiRoot? FindRootOf(UiElement element)
{
for (UiElement? at = element; at is not null; at = at.Parent)

View file

@ -115,7 +115,7 @@ public static class DatWidgetFactory
// pre-OP2 UiDatElement fallback (media drawn, ClickThrough=true, state
// propagation) because nothing ever activates it.
5 => new UiTemplateListBox(info, resolve, info.TemplateList, info.ScrollbarElementId),
6 => new UiMenu(), // UIElement_Menu (reg :120163)
6 => BuildMenu(info, resolve, elementFont, fontResolve), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, elementFont, stringResolve), // UIElement_Meter
// UIElement_Panel (Type 8) — retail's tab-strip host (dat property 0x2E;
// research doc §1.3/§10.1). OP2 rework (docs/research/2026-08-11-op2-
@ -133,6 +133,7 @@ public static class DatWidgetFactory
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
0x13 => new UiDialogRoot(), // ConfirmationDialog
0x14 => new UiDialogRoot(), // ConfirmationMenuDialog
0x15 => new UiDialogRoot(), // ConfirmationTextInputDialog
0x17 => new UiDialogRoot(), // MessageDialog
0x19 => new UiDialogRoot(), // WaitDialog (catalog root 0x31 — OP8 #396)
@ -163,7 +164,7 @@ public static class DatWidgetFactory
// ArrowCapClosedSprite doc comment). Built blank, exactly like the Type-6
// case above — a page controller wires its sprites/items the same way
// ChatWindowController wires the channel menu.
0x10000038u => new UiMenu(),
0x10000038u => BuildMenu(info, resolve, elementFont, fontResolve),
// UIOption_CheckboxBitfield64 (Type 0x10000044): the Chat tab's per-window
// text-filter block. OP2 rework (docs/research/2026-08-11-op2-review-
// mechanism.md MUST-FIX 4): the authored template (0x10000520) DOES author
@ -209,6 +210,42 @@ public static class DatWidgetFactory
return e;
}
/// <summary>
/// Retail's generic menu class supplies its standard face/popup chrome even
/// when no game-specific controller customizes it. This matters for catalog
/// dialogs such as ConfirmationMenu: their Type-6 leaf is the whole control,
/// and <see cref="UiMenu.ConsumesDatChildren"/> intentionally absorbs the
/// authored label child. Existing chat/vendor/options controllers overwrite
/// these defaults with their own probed variants.
/// </summary>
private static UiMenu BuildMenu(
ElementInfo info,
Func<uint, (uint, int, int)> resolve,
UiDatFont? elementFont,
Func<uint, UiDatFont?>? fontResolve)
{
ElementInfo? label = info.Children.FirstOrDefault(
static child => child.Type == 12u);
UiDatFont? labelFont = label is { FontDid: not 0u } && fontResolve is not null
? fontResolve(label.FontDid) ?? elementFont
: elementFont;
var menu = new UiMenu
{
SpriteResolve = resolve,
DatFont = labelFont,
ButtonDatFont = labelFont,
NormalSprite = 0x06004D65u,
PressedSprite = 0x06004D66u,
PopupBgSprite = 0x0600124Cu,
ItemNormalSprite = 0x0600124Eu,
ItemHighlightSprite = 0x0600124Du,
ButtonTextCentered = label?.HJustify == HJustify.Center,
};
if (label?.FontColor is { } color)
menu.TextColor = color;
return menu;
}
/// <summary>
/// Bind inherited scrollbar media structurally. Property 0x77 names the
/// increment button and 0x78 the decrement button; retail

View file

@ -43,6 +43,7 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
private readonly UiItemList _contentsList;
private uint _openContainer;
private PendingBackpackPlacement? _pendingPlacement;
private bool _closeRequested;
private bool _disposed;
@ -115,6 +116,9 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
_objects.Cleared += OnObjectsCleared;
_selection.Changed += OnSelectionChanged;
_itemInteraction.StateChanged += OnInteractionStateChanged;
_itemInteraction.PendingBackpackPlacementRequested += OnPendingPlacementRequested;
_itemInteraction.PendingBackpackPlacementCancelled += OnPendingPlacementCancelled;
_itemInteraction.PendingBackpackPlacementResolved += OnPendingPlacementResolved;
ClearLists();
}
@ -210,13 +214,14 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
UiItemSlot targetCell,
ItemDragPayload payload)
{
if (payload.SourceKind == ItemDragSource.ShortcutBar)
return ItemDragAcceptance.None;
if (!ReferenceEquals(targetList, _contentsList)
|| payload.SourceKind == ItemDragSource.ShortcutBar
|| payload.ObjId == 0u
|| _openContainer == 0u
|| payload.ObjId == _openContainer)
|| _openContainer == 0u)
return ItemDragAcceptance.Reject;
return ItemDragAcceptance.Accept;
return EvaluateDrop(payload.ObjId) == InventoryContainerPlacementRejection.None
? ItemDragAcceptance.Accept
: ItemDragAcceptance.Reject;
}
public void HandleDropRelease(
@ -224,8 +229,19 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
UiItemSlot targetCell,
ItemDragPayload payload)
{
if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept)
InventoryContainerPlacementRejection legality = EvaluateDrop(payload.ObjId);
if (legality != InventoryContainerPlacementRejection.None)
{
if (InventoryContainerPlacementPolicy.ComposeClientLocal(
legality,
_objects.Get(payload.ObjId),
_objects.Get(_openContainer),
playerId: 0u) is { } refusal)
{
_itemInteraction.ReportClientLocal(refusal);
}
return;
}
if (!_itemInteraction.EnsureInventoryRequestReady())
return;
if (_objects.Get(payload.ObjId) is not { } item)
@ -246,17 +262,30 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
InventoryRequestKind kind = amount < fullStack
? InventoryRequestKind.SplitToContainer
: InventoryRequestKind.PutInContainer;
_itemInteraction.TryDispatchInventoryRequest(
kind,
item.ObjectId,
() =>
{
if (amount < fullStack)
if (amount < fullStack)
{
_itemInteraction.TryDispatchInventoryRequest(
kind,
item.ObjectId,
() =>
{
_sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount);
else
return true;
});
}
else
{
_itemInteraction.TryDispatchPendingBackpackPlacement(
item.ObjectId,
_openContainer,
placement,
kind,
() =>
{
_sendPutItemInContainer(item.ObjectId, _openContainer, placement);
return true;
});
return true;
});
}
}
private void OnExternalContainerChanged(ExternalContainerTransition transition)
@ -314,10 +343,29 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
AddContainerCell(guid);
}
var visibleContents = new List<uint>();
foreach (uint guid in _objects.GetContents(_openContainer))
{
if (!IsContainer(_objects.Get(guid)))
AddContentsCell(guid);
visibleContents.Add(guid);
}
if (_pendingPlacement is { } pending
&& pending.ContainerId == _openContainer
&& _objects.Get(pending.ItemId) is { } pendingItem
&& !IsContainer(pendingItem))
{
visibleContents.Remove(pending.ItemId);
visibleContents.Insert(
Math.Clamp(pending.Placement, 0, visibleContents.Count),
pending.ItemId);
}
foreach (uint guid in visibleContents)
{
bool waiting = _itemInteraction.IsPendingInventorySource(guid)
|| _pendingPlacement is { } projection
&& projection.ContainerId == _openContainer
&& projection.ItemId == guid;
AddContentsCell(guid, waiting);
}
ApplyIndicators();
}
@ -334,15 +382,15 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
private void AddContainerCell(uint guid)
{
UiItemSlot cell = CreateCell(_containerList, guid, ItemDragSource.Ground);
cell.Clicked = () => OpenNestedContainer(guid);
SetCapacity(cell, guid);
_containerList.AddItem(cell);
}
private void AddContentsCell(uint guid)
private void AddContentsCell(uint guid, bool waiting = false)
{
UiItemSlot cell = CreateCell(_contentsList, guid, ItemDragSource.Ground);
cell.DoubleClicked = () => _itemInteraction.ActivateItem(guid);
cell.SetWaitingState(waiting);
cell.DragAcceptSprite = 0x060011F9u;
cell.DragRejectSprite = 0x060011F8u;
_contentsList.AddItem(cell);
@ -387,7 +435,10 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
{
if (_itemInteraction.OfferPrimaryClick(guid) != ItemPrimaryClickResult.NotActive)
return true;
Select(guid);
if (IsContainer(_objects.Get(guid)) && guid != _state.CurrentContainerId)
OpenNestedContainer(guid);
else
Select(guid);
return false;
}
@ -406,7 +457,8 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
bool pendingSource = _itemInteraction.IsPendingSource(cell.ItemId);
cell.Selected = cell.ItemId != 0u
&& cell.ItemId == _selection.SelectedObjectId
&& !pendingSource;
&& !pendingSource
&& !_itemInteraction.IsPendingInventorySource(cell.ItemId);
cell.IsOpenContainer = cell.ItemId != 0u && cell.ItemId == _openContainer;
}
}
@ -486,7 +538,48 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
}
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
private void OnInteractionStateChanged() => ApplyIndicators();
private void OnInteractionStateChanged()
{
if (_window.IsVisible)
Populate();
else
ApplyIndicators();
}
private void OnPendingPlacementRequested(PendingBackpackPlacement pending)
{
if (pending.ContainerId != _openContainer)
return;
_pendingPlacement = pending;
if (_window.IsVisible)
Populate();
}
private void OnPendingPlacementCancelled(PendingBackpackPlacement pending)
=> ResolvePendingPlacement(pending);
private void OnPendingPlacementResolved(PendingBackpackPlacement pending)
=> ResolvePendingPlacement(pending);
private void ResolvePendingPlacement(PendingBackpackPlacement pending)
{
if (_pendingPlacement is not { } current || current.Token != pending.Token)
return;
_pendingPlacement = null;
if (_window.IsVisible)
Populate();
}
private InventoryContainerPlacementRejection EvaluateDrop(uint itemId)
{
if (_objects.Get(itemId) is { } source && IsContainer(source))
return InventoryContainerPlacementRejection.ContainerCapacityFull;
return InventoryContainerPlacementPolicy.Evaluate(
_objects,
itemId,
_openContainer,
playerId: 0u);
}
private static bool IsContainer(ClientObject? item)
=> item is not null
@ -573,6 +666,9 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
_objects.Cleared -= OnObjectsCleared;
_selection.Changed -= OnSelectionChanged;
_itemInteraction.StateChanged -= OnInteractionStateChanged;
_itemInteraction.PendingBackpackPlacementRequested -= OnPendingPlacementRequested;
_itemInteraction.PendingBackpackPlacementCancelled -= OnPendingPlacementCancelled;
_itemInteraction.PendingBackpackPlacementResolved -= OnPendingPlacementResolved;
_topContainer.PrimaryItemPressed = null;
_containerList.PrimaryItemPressed = null;
_contentsList.PrimaryItemPressed = null;

View file

@ -116,6 +116,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_itemInteraction = itemInteraction;
_stackSplitQuantity = stackSplitQuantity;
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
if (_itemInteraction is not null)
_itemInteraction.MergeAttempted += OnMergeAttempted;
WindowChromeController.BindCloseButton(layout, onClose);
@ -299,14 +301,13 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
if (containerId == EffectiveOpen() || containerId == _playerGuid())
Populate();
}
private void OnInteractionStateChanged() => ApplyIndicators();
private void OnInteractionStateChanged() => Populate();
private void OnPendingBackpackPlacementRequested(PendingBackpackPlacement pending)
{
if (_pendingListPlacement is not null
|| pending.ItemId == 0u
|| pending.ContainerId != EffectiveOpen()
|| _objects.Get(pending.ItemId) is not { } item
|| IsBag(item))
|| pending.ContainerId == 0u
|| _objects.Get(pending.ItemId) is null)
{
return;
}
@ -375,12 +376,33 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
// Side-bag column: ALWAYS the player's bags (constant across container switches; only the
// open/selected indicators move). Equipped items never appear here.
var visibleBags = new List<uint>();
foreach (var guid in _objects.GetContents(p))
{
var item = _objects.Get(guid);
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
bool isBag = IsBag(item);
if (isBag) AddCell(_containerList, guid, isContainer: true);
if (isBag) visibleBags.Add(guid);
}
PendingListPlacement? pending = _pendingListPlacement;
if (pending is { } bagProjection
&& bagProjection.ContainerId == p
&& _objects.Get(bagProjection.ItemId) is { } pendingBag
&& IsBag(pendingBag))
{
visibleBags.Remove(bagProjection.ItemId);
int index = Math.Clamp(bagProjection.Placement, 0, visibleBags.Count);
visibleBags.Insert(index, bagProjection.ItemId);
}
foreach (uint guid in visibleBags)
{
bool waiting = IsWaitingSource(guid)
|| pending is { } waitingBagProjection
&& waitingBagProjection.ContainerId == p
&& waitingBagProjection.ItemId == guid;
AddCell(_containerList, guid, isContainer: true, waiting);
}
// Contents grid: the OPEN container's loose items. (Bags live in the column; a side bag has
@ -394,20 +416,20 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
if (!isBag) visibleContents.Add(guid);
}
PendingListPlacement? pending = _pendingListPlacement;
if (pending is { } projection
&& projection.ContainerId == open
&& !visibleContents.Contains(projection.ItemId)
&& _objects.Get(projection.ItemId) is { } pendingItem
&& !IsBag(pendingItem))
{
visibleContents.Remove(projection.ItemId);
int index = Math.Clamp(projection.Placement, 0, visibleContents.Count);
visibleContents.Insert(index, projection.ItemId);
}
foreach (uint guid in visibleContents)
{
bool waiting = pending is { } waitingProjection
bool waiting = IsWaitingSource(guid)
|| pending is { } waitingProjection
&& waitingProjection.ContainerId == open
&& waitingProjection.ItemId == guid;
AddCell(_contentsGrid, guid, isContainer: false, waiting);
@ -455,8 +477,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
dragIconTexture: _dragIconIds?.Invoke(
ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u) ?? 0u);
main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u;
main.SetWaitingState(IsWaitingSource(p));
main.Clicked = () => OpenContainer(p);
main.DoubleClicked = () => _itemInteraction?.ActivateItem(p);
SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity)
_topContainer.AddItem(main);
}
@ -474,6 +496,21 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|| item.Type.HasFlag(ItemType.Container)
|| item.ItemsCapacity > 0;
private int CountLooseContents(uint containerId)
{
int count = 0;
foreach (uint guid in _objects.GetContents(containerId))
{
if (_objects.Get(guid) is { } item
&& item.CurrentlyEquippedLocation == EquipMask.None
&& !IsBag(item))
{
count++;
}
}
return count;
}
private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid();
/// <summary>The owned destination retail PlaceInBackpack currently uses.</summary>
@ -499,12 +536,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
cell.SetWaitingState(waiting);
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)
ConfigureDropFeedback(list, cell);
cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid);
if (isContainer)
{
cell.Clicked = () => OpenContainer(guid);
SetCapacityBar(cell, guid);
}
else
{
cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid);
}
list.AddItem(cell);
}
@ -513,7 +553,10 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
if (_itemInteraction?.OfferPrimaryClick(guid)
is not null and not ItemPrimaryClickResult.NotActive)
return true;
SelectItem(guid);
if (_objects.Get(guid) is { } item && IsBag(item))
OpenContainer(guid);
else
SelectItem(guid);
return false;
}
@ -522,7 +565,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
if (_itemInteraction?.OfferSelfPrimaryClick()
is not null and not ItemPrimaryClickResult.NotActive)
return true;
SelectItem(guid);
OpenContainer(guid);
return false;
}
@ -556,11 +599,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{
int cap = _objects.Get(containerGuid)?.ItemsCapacity ?? 0;
if (cap <= 0) { cell.CapacityFill = -1f; return; }
int n = _objects.GetContents(containerGuid).Count;
// Player contents contain two independent retail lists: loose items
// and side packs. ItemsCapacity applies only to the former; counting
// packs here made a main pack stay visually/full logically rejected
// even after the player freed an item slot.
int n = CountLooseContents(containerGuid);
cell.CapacityFill = Math.Clamp(n / (float)cap, 0f, 1f);
}
// ── IItemListDragHandler (B-Drag) — drop an item to move it (optimistic + wire) ──────────────
// ── IItemListDragHandler (B-Drag) — request first; server owns placement ────────────────────
/// <summary>Retail ItemList_BeginDrag selects an unselected item before enabling its waiting
/// mesh. Inventory items do not lift-remove (unlike the toolbar): the item stays in its slot
/// until the server confirms the eventual drop.</summary>
@ -583,35 +630,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
// remove-on-lift stands.
if (payload.SourceKind == ItemDragSource.ShortcutBar)
return ItemDragAcceptance.None;
if (payload.ObjId == 0)
return ItemDragAcceptance.Reject;
bool sourceIsBag = _objects.Get(payload.ObjId) is { } source && IsBag(source);
if (targetList == _contentsGrid)
return sourceIsBag
? ItemDragAcceptance.Reject
: ItemDragAcceptance.Accept;
if (targetList == _containerList || targetList == _topContainer)
{
// UIElement_ItemList::ItemList_DragOver @0x004E3400 checks the
// dragged object's container flag before interpreting this list.
// A container drag addresses the player's contained-container
// list itself; an empty authored slot is therefore a valid pack
// destination rather than "no target".
if (sourceIsBag)
return targetCell.ItemId == payload.ObjId
? ItemDragAcceptance.Reject
: ItemDragAcceptance.Accept;
if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId)
return ItemDragAcceptance.Reject;
return IsContainerFull(targetCell.ItemId)
? ItemDragAcceptance.Reject
: ItemDragAcceptance.Accept;
}
return ItemDragAcceptance.Reject;
return EvaluateDrop(targetList, targetCell, payload.ObjId, out _, out _)
== InventoryContainerPlacementRejection.None
? ItemDragAcceptance.Accept
: ItemDragAcceptance.Reject;
}
/// <summary>Resolve the destination and either split or move the stack. A partial split waits
/// for the server-created object's guid; a whole move remains optimistic. Retail:
/// for the server-created object's guid; a whole move displays only the
/// destination list's waiting projection until the server responds. Retail:
/// <c>ItemHolder::AttemptToPlaceInContainer @ 0x00588140</c>.</summary>
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
@ -627,8 +654,24 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
// DropReleased is still delivered to the list after a reject overlay;
// pin the release to the same retail policy instead of relying on the
// advisory color alone.
if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept)
InventoryContainerPlacementRejection legality = EvaluateDrop(
targetList,
targetCell,
item,
out _,
out uint legalityDestination);
if (legality != InventoryContainerPlacementRejection.None)
{
if (InventoryContainerPlacementPolicy.ComposeClientLocal(
legality,
_objects.Get(item),
_objects.Get(legalityDestination),
_playerGuid()) is { } refusal)
{
_itemInteraction?.ReportClientLocal(refusal);
}
return;
}
// UIElement_ItemList::AcceptDragObject @ 0x004E4250 rejects every
// release while m_pendingItem exists, before merge, split, or ordinary
@ -662,7 +705,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
container = EffectiveOpen();
placement = targetCell.ItemId != 0
? targetCell.SlotIndex // insert-before = the target's GRID INDEX (gapless), not its raw ContainerSlot
: _objects.GetContents(container).Count; // first empty = append
: CountLooseContents(container); // first empty = append after visible loose items
}
else if (targetList == _containerList || targetList == _topContainer)
{
@ -697,79 +740,65 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{
// UIAttemptSplitToContainer leaves the source stack where it is. ACE will
// publish the reduced source plus a newly-guided destination stack.
DispatchInventoryRequest(
InventoryRequestKind.SplitToContainer,
item,
() =>
{
if (_sendStackableSplitToContainer is null)
return false;
_sendStackableSplitToContainer(
item,
container,
(uint)placement,
splitSize);
return true;
});
if (_itemInteraction is not null)
{
_itemInteraction.TrySplitToContainer(
item,
container,
(uint)placement,
splitSize);
}
else
{
DispatchInventoryRequest(
InventoryRequestKind.SplitToContainer,
item,
() =>
{
if (_sendStackableSplitToContainer is null)
return false;
_sendStackableSplitToContainer(
item,
container,
(uint)placement,
splitSize);
return true;
});
}
return;
}
}
// External-container contents retain canonical ownership while the request
// is in flight, but retail immediately inserts an m_pendingItem copy into
// the chosen destination slot and ghosts it. The server move/failure notice
// resolves that visual projection. UIElement_ItemList::HandleDropRelease
// @ 0x004E4790; ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680.
if (payload.SourceKind == ItemDragSource.Ground)
// Canonical ownership never changes on request. Retail immediately
// publishes the destination ItemList's m_pendingItem projection and
// resolves it from the server move/failure response.
if (_itemInteraction is not null)
{
if (_itemInteraction is not null)
{
if (!_itemInteraction.TryDispatchPendingBackpackPlacement(
item,
container,
placement,
InventoryRequestKind.Pickup,
() =>
{
if (_sendPutItemInContainer is null)
return false;
_sendPutItemInContainer(item, container, placement);
return true;
}))
{
return;
}
return;
}
else
{
if (_pendingListPlacement is not null)
return;
_pendingListPlacement = new PendingListPlacement(0u, item, container, placement);
Populate();
}
}
else
{
if (_itemInteraction is not null)
{
DispatchInventoryRequest(
InventoryRequestKind.PutInContainer,
InventoryRequestKind kind = payload.SourceKind == ItemDragSource.Ground
? InventoryRequestKind.Pickup
: InventoryRequestKind.PutInContainer;
if (!_itemInteraction.TryDispatchPendingBackpackPlacement(
item,
container,
placement,
kind,
() =>
{
if (_sendPutItemInContainer is null
|| !_objects.MoveItemOptimistic(item, container, placement))
{
if (_sendPutItemInContainer is null)
return false;
}
_sendPutItemInContainer(item, container, placement);
return true;
});
}))
{
return;
}
_objects.MoveItemOptimistic(item, container, placement);
return;
}
if (_pendingListPlacement is not null)
return;
_pendingListPlacement = new PendingListPlacement(0u, item, container, placement);
Populate();
_sendPutItemInContainer?.Invoke(item, container, placement);
}
@ -832,9 +861,57 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{
int cap = _objects.Get(container)?.ItemsCapacity ?? 0;
if (cap <= 0) return false;
return _objects.GetContents(container).Count >= cap;
return CountLooseContents(container) >= cap;
}
private void OnMergeAttempted(uint sourceId, uint targetId)
{
_notifyMergeAttempt?.Invoke(sourceId, targetId);
_selection.Select(targetId, SelectionChangeSource.Inventory);
}
private InventoryContainerPlacementRejection EvaluateDrop(
UiItemList targetList,
UiItemSlot targetCell,
uint itemId,
out bool sourceIsBag,
out uint destinationId)
{
sourceIsBag = _objects.Get(itemId) is { } source && IsBag(source);
destinationId = 0u;
if (itemId == 0u)
return InventoryContainerPlacementRejection.InvalidItem;
if (ReferenceEquals(targetList, _contentsGrid))
{
destinationId = EffectiveOpen();
// Carried containers belong to the authored container selector,
// never the loose-item grid, even when both address the player.
if (sourceIsBag)
return InventoryContainerPlacementRejection.ContainerCapacityFull;
}
else if (ReferenceEquals(targetList, _containerList)
|| ReferenceEquals(targetList, _topContainer))
{
destinationId = sourceIsBag ? _playerGuid() : targetCell.ItemId;
if (!sourceIsBag && (targetCell.ItemId == 0u || targetCell.ItemId == itemId))
return InventoryContainerPlacementRejection.InvalidDestination;
}
else
{
return InventoryContainerPlacementRejection.InvalidDestination;
}
return InventoryContainerPlacementPolicy.Evaluate(
_objects,
itemId,
destinationId,
_playerGuid());
}
private bool IsWaitingSource(uint itemGuid)
=> _itemInteraction?.IsPendingInventorySource(itemGuid) == true;
/// <summary>Select an item (panel-wide green square) without changing the open container or
/// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0).</summary>
private void SelectItem(uint guid)
@ -895,7 +972,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{
var cell = list.GetItem(i);
if (cell is null) continue;
bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true;
bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true
|| IsWaitingSource(cell.ItemId);
cell.Selected = cell.ItemId != 0
&& cell.ItemId == _selection.SelectedObjectId
&& !pendingTargetSource;
@ -1012,6 +1090,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
}
if (_itemInteraction is not null)
{
_itemInteraction.MergeAttempted -= OnMergeAttempted;
_itemInteraction.StateChanged -= OnInteractionStateChanged;
_itemInteraction.PendingBackpackPlacementRequested -= OnPendingBackpackPlacementRequested;
_itemInteraction.PendingBackpackPlacementCancelled -= OnPendingBackpackPlacementCancelled;

View file

@ -180,6 +180,9 @@ public sealed class JournalPanelController : IRetainedPanelController
/// <summary>Switches to the notes tab — what opening a page from the index does.</summary>
public void ShowNotes() => _tabPanel.SwitchTo(NotesPageId);
/// <summary>Switches to the authored journal index tab.</summary>
public void ShowPageList() => _tabPanel.SwitchTo(PageListPageId);
/// <summary>
/// Completes construction. The index needs a callback that switches tabs,
/// which needs the panel — so it is attached rather than constructed.

View file

@ -66,19 +66,17 @@ namespace AcDream.App.UI.Layout;
///
/// <para>
/// <b>Row identity and binding storage (D4).</b> Every row's identity is the DAT
/// pair <c>(InputMapId, ActionId)</c> — retail's own row key. Where
/// <see cref="RetailActionIdentityTable"/> resolves that pair to an acdream
/// <see cref="InputAction"/> (research: roughly half of the DAT's 306 rows — see
/// that table's class doc for the full accounting), the row's bindings ARE
/// pair <c>(InputMapId, ActionId)</c> — retail's own row key. The installed EoR
/// ActionMap's 306 pairs each resolve to one distinct acdream
/// <see cref="InputAction"/>; the row's bindings ARE
/// <see cref="KeyBindings"/>'s bindings for that action: a rebind here takes
/// effect immediately for live gameplay dispatch through the SAME
/// <see cref="InputDispatcher"/> every other input path uses, and persists to
/// <c>keybinds.json</c> exactly like any other rebind (D4 — no separate
/// <c>.keymap</c> file format). Where no <see cref="InputAction"/> exists yet
/// (mostly Emotes and CharacterSettings — see the identity table's class doc),
/// the row is still fully rendered, bindable, conflict-checked, and persisted
/// (<see cref="Bindings.CurrentForUnmapped"/>/<see cref="Bindings.SetForUnmapped"/>),
/// it just has no live gameplay consumer yet (register row).
/// <see cref="InputDispatcher"/> every other input path uses. Retail Load File /
/// Save As exchange the original PFile <c>*.keymap</c> format; acdream also writes
/// <c>keybinds.json</c> as its portable mirror for host-only commands. The
/// nullable/unmapped delegates remain solely
/// so an unknown future-DAT row stays visible and round-trippable instead of
/// crashing an older client.
/// </para>
///
/// <para>
@ -90,8 +88,8 @@ namespace AcDream.App.UI.Layout;
/// against every multi-chord action in <c>KeyBindings.RetailDefaults()</c>:
/// walk-mode's Hold, the three melee/missile/magic combat scopes, ...), so this
/// row captures that pair ONCE at build time (from the first live binding, or
/// <see cref="ActivationType.Press"/>/<see cref="InputScope.Game"/> if the action
/// starts wholly unbound) and reapplies it to every chord this row ever writes —
/// the retail identity table if the action starts wholly unbound) and reapplies
/// it to every chord this row ever writes —
/// on a live rebind, on Cancel/Revert (<c>RestoreSavedValue</c>), and on Defaults
/// (<c>RestoreDefaultValue</c>, which restores DAT-sourced KEYS only; Activation/
/// Scope are retail-side properties of the ACTION, not of which physical key
@ -113,9 +111,8 @@ namespace AcDream.App.UI.Layout;
/// ANY conflicting target is non-user-bindable). This port's non-user-bindable
/// analogue is a chord already bound to an acdream-only action with no
/// <see cref="RetailActionIdentityTable"/> row at all (Ctrl+M mute, the debug
/// F-keys, ...) — refused via <see cref="Bindings.NonBindableRefusalText"/>
/// exactly like retail's distinct <c>OpenCantOverwriteBindingDialog</c>, with no
/// dialog (a hard stop, matching the DAT-verified refusal string). A genuine
/// F-keys, ...) — refused through retail's type-3
/// <c>OpenCantOverwriteBindingDialog</c> with the exact DAT template. A genuine
/// cross-row conflict collects EVERY conflicting row (not just the first) and
/// opens a real confirm dialog through <see cref="Bindings.ConfirmOverwrite"/> —
/// retail's <c>OpenOverwriteBindingDialog(&amp;conflicts)</c> — BEFORE reassigning;
@ -123,15 +120,10 @@ namespace AcDream.App.UI.Layout;
/// </para>
///
/// <para>
/// <b>Caption dimming (AD-78, user-directed, 2026-08-11, gate 2).</b> A row
/// whose <see cref="RowView.MappedAction"/> is null (AP-203's store-only
/// set — mostly Emotes and CharacterSettings, plus every non-user-bindable
/// InputMap this screen renders) dims its synthesized caption via
/// <see cref="UiRenderContext.StoreOnlyCaptionColor"/> in
/// <see cref="BuildActionRow"/>. The row stays fully rendered, bindable,
/// conflict-checked, and persisted (per the paragraph above) — only the
/// caption color changes, so the dim is a visual "no live gameplay consumer
/// yet" marker, not a functional restriction.
/// Campaign KB maps all 306 installed EoR rows to distinct live actions, so
/// every authored command is enabled and uses the normal caption color. The
/// nullable defensive path remains only to make an unknown future DAT row
/// visible without crashing an older client.
/// </para>
/// </summary>
public sealed class KeyboardConfigController
@ -204,8 +196,14 @@ public sealed class KeyboardConfigController
Action<Action<KeyChord?>> BeginCapture,
Action Save,
Action Toggle,
Action<string> DisplaySystemMessage,
string NonBindableRefusalText,
// Resolves one of retail's ID_ActionKeyMap_* templates from string-table
// enum 0x10000004 (installed DID 0x23000004). Null means the retail text
// is unavailable; callers then leave the operation inert instead of
// inventing UI prose.
Func<string, IReadOnlyDictionary<uint, string>, string?> ResolveTemplate,
// Retail OpenCantOverwriteBindingDialog is a type-3 priority message on
// keyboard queue 0x10000001, not a scrolling-chat/system message.
Action<string> ShowMessage,
// M3 (2026-08-11 review): retail's OpenOverwriteBindingDialog — confirm
// BEFORE reassigning a chord already bound to another row on this screen.
// message is pre-composed (real row labels, no invented retail text);
@ -221,25 +219,36 @@ public sealed class KeyboardConfigController
// or ESC). Null keeps the pre-dialog capture behavior for hosts with
// no dialog factory (unit fixtures).
Func<string, uint>? OpenCaptureInstructions = null,
Action<uint>? CloseCaptureInstructions = null);
Action<uint>? CloseCaptureInstructions = null,
// Retail gmKeyboardUI's Load File / Save As workflows. Each opener
// invokes its callback only after a successful profile operation.
Func<string>? CurrentKeymapFilename = null,
Action<Action>? OpenLoadKeymap = null,
Action<Action>? OpenSaveKeymap = null);
public OptionPage Page { get; } = new();
public IReadOnlyList<RowView> Rows => _rows;
private readonly List<RowView> _rows = new();
private readonly Dictionary<(uint LayoutId, uint ElementId), UiDatFont?> _templateFontCache = new();
private RetailActionMapSnapshot? _snapshot;
private Bindings? _bindings;
private Func<KeyChord, string> _describe = DescribeChord;
private Func<uint, uint, UiDatFont?>? _resolveTemplateFont;
private static readonly uint ActionVariable = DatStringResolver.ComputeHash("ACTION");
private static readonly uint BindingsVariable = DatStringResolver.ComputeHash("BINDINGS");
private static readonly uint KeyVariable = DatStringResolver.ComputeHash("KEY");
private static readonly uint LabelVariable = DatStringResolver.ComputeHash("LABEL");
private static readonly uint ValueVariable = DatStringResolver.ComputeHash("VALUE");
private KeyboardConfigController() { }
/// <summary>
/// Builds every header + row across all six pages from
/// <paramref name="snapshot"/>, wires each row's key buttons to modal
/// capture / right-click erase, and wires the screen's own six buttons
/// (Defaults/Revert/OK/Cancel; Load/Save File are INERT — D4, no
/// <c>.keymap</c> interchange). Returns null if the layout's window root
/// (Load File/Save As/Defaults/Revert/OK/Cancel). Returns null if the layout's window root
/// did not import (a missing/malformed LayoutDesc).
/// </summary>
public static KeyboardConfigController? Bind(
@ -266,6 +275,7 @@ public sealed class KeyboardConfigController
var controller = new KeyboardConfigController
{
_snapshot = snapshot,
_bindings = bindings,
_resolveTemplateFont = resolveTemplateFont,
// OP8 re-gate (2026-08-14): key-button captions through retail's
@ -397,11 +407,9 @@ public sealed class KeyboardConfigController
// The row's own caption — synthesized, composed beside the authored key
// buttons (UiText is sealed; see class doc). Occupies the "Command" column
// (x=0..270, matching the authored column headers). AD-78 (user-directed,
// 2026-08-11, gate 2): an unmapped row (MappedAction null — no live
// InputDispatcher consumer, AP-203) dims its caption; the key buttons
// themselves stay fully interactive (bindable/persisted/conflict-checked,
// see class doc).
// (x=0..270, matching the authored column headers). All 306 EoR rows
// are mapped; the dim color is only a forward-compatible signal for
// a row introduced by a different DAT revision.
var captionText = new UiText
{
Left = 0f,
@ -423,39 +431,41 @@ public sealed class KeyboardConfigController
};
if (label is not null)
captionText.LinesProvider = () => new[] { new UiText.Line(label, captionText.DefaultColor) };
// UIOption_ActionKeyMap::SetTooltip applies the ActionMap row's own
// tooltip to the row, while Refresh replaces each key button's tooltip
// with the dedicated existing/new-binding templates below.
captionText.AuthoredTooltipText = tooltip;
built.AddChild(captionText);
// M1: capture this row's live Activation/Scope ONCE, from the first
// existing binding for the action (every multi-chord action in
// KeyBindings.RetailDefaults() shares one Activation/Scope pair across
// all its bindings — see class doc). Falls back to the Binding record's
// own defaults (Press/Game) only when the action starts wholly unbound.
// retail action-identity metadata when the action starts wholly unbound.
IReadOnlyList<Binding> liveBindings = mapped
? bindings.CurrentForAction(action)
: Array.Empty<Binding>();
(ActivationType Activation, InputScope Scope) template = liveBindings.Count > 0
? (liveBindings[0].Activation, liveBindings[0].Scope)
: (ActivationType.Press, InputScope.Game);
: (
RetailActionIdentityTable.ActivationFor(row.InputMapId, row.ActionId),
RetailActionIdentityTable.ScopeForInputMap(row.InputMapId));
IReadOnlyList<KeyChord> defaults = DatDefaultsToChords(row.DefaultBindings);
IReadOnlyList<KeyChord> storedUnmapped = mapped
? Array.Empty<KeyChord>()
: bindings.CurrentForUnmapped((row.InputMapId, row.ActionId));
// OP8 re-review round 2 (SHOULD-FIX): an unmapped/store-only row with
// no persisted chords displays its DAT DEFAULTS — retail shows the
// authored bindings (the Camera Alternate rows' arrow keys) and a
// blank row misreads as "unbound". Display-only: nothing here feeds
// the InputDispatcher, and the store only gains the defaults if the
// user actually edits the row (the apply closure below).
// An unknown future-DAT row with no persisted chords displays its DAT
// defaults. Installed EoR rows always take the mapped branch.
IReadOnlyList<KeyChord> initial = mapped
? liveBindings.Select(b => b.Chord).ToArray()
: storedUnmapped.Count > 0 ? storedUnmapped : defaults;
var model = new ActionKeyMapOptionRow(initial, defaults, apply: value =>
{
// Interior/padding default(KeyChord) entries (S4 — sparse-slot
// display, see ReplaceSlotValue) are never real bindings; filter
// them out at the write boundary, not at storage time.
// A legacy compatibility store can still contain padding
// default(KeyChord) entries even though the retail production
// editor is dense; never publish those sentinels as bindings.
IReadOnlyList<KeyChord> real = value.Where(c => c != default).ToArray();
if (mapped)
bindings.SetForAction(
@ -474,7 +484,6 @@ public sealed class KeyboardConfigController
for (int slot = 0; slot < keyButtons.Count; slot++)
{
int capturedSlot = slot;
keyButtons[slot].TooltipText = tooltip;
keyButtons[slot].OnClick = () => BeginSlotCapture(view, capturedSlot, bindings);
keyButtons[slot].OnRightClick = () => EraseSlot(view, capturedSlot);
}
@ -516,10 +525,36 @@ public sealed class KeyboardConfigController
for (int i = 0; i < view.KeyButtons.Count; i++)
{
bool bound = i < current.Count && current[i] != default;
view.KeyButtons[i].Label = bound ? _describe(current[i]) : null;
if (!bound)
{
view.KeyButtons[i].Label = null;
view.KeyButtons[i].TooltipText = ResolveTemplate(
"ID_ActionKeyMap_TT_NewBinding",
EmptyTemplateVariables);
continue;
}
string keyName = _describe(current[i]);
string? buttonLabel = ResolveTemplate(
"ID_ActionKeyMap_ButtonLabel",
new Dictionary<uint, string> { [LabelVariable] = keyName });
view.KeyButtons[i].Label = buttonLabel;
view.KeyButtons[i].TooltipText = buttonLabel is null
? null
: ResolveTemplate(
"ID_ActionKeyMap_TT_ExistingBinding",
new Dictionary<uint, string> { [ValueVariable] = buttonLabel });
}
}
private static readonly IReadOnlyDictionary<uint, string> EmptyTemplateVariables =
new Dictionary<uint, string>();
private string? ResolveTemplate(
string key,
IReadOnlyDictionary<uint, string> variables) =>
_bindings?.ResolveTemplate(key, variables);
/// <summary>Raw enum spelling — construction-time default until Bind swaps
/// in <see cref="RetailKeyNames.Describe"/>, and that class's own fallback
/// for controls outside the DIK table.</summary>
@ -548,13 +583,34 @@ public sealed class KeyboardConfigController
}
}
bindings.BeginCapture(captured =>
void ArmCapture() => bindings.BeginCapture(captured =>
{
if (captured is { } unsupported && IsUnsupportedRetailCapture(unsupported))
{
// KeyHitHandler @0x004895AF..0x004895DF leaves its input
// handler registered for joystick input and mouse buttons 0/1.
// The authored MapInstructions says the same explicitly. Our
// dispatcher capture is one-shot, so re-arm it while leaving
// the existing wait dialog open.
ArmCapture();
return;
}
if (instructionsContext != 0u)
bindings.CloseCaptureInstructions?.Invoke(instructionsContext);
if (captured is not { } chord) return; // Escape — retail cancels silently.
// KeyHitHandler @ 0x0048963B..0x0048964A checks the row's own
// current controls for an EXACT match before it performs any
// cross-map conflict work. Choosing a chord already present in a
// different slot of this row is therefore a silent no-op; it must
// not duplicate the chord into the clicked slot (and must not be
// rejected because an unrelated non-user-bindable action happens
// to share it).
if (view.Model.Current.Contains(chord))
return;
(ConflictOutcome outcome, List<RowView> conflictRows) = FindConflicts(chord, exclude: view);
switch (outcome)
{
@ -564,18 +620,23 @@ public sealed class KeyboardConfigController
// conflicting target is non-user-bindable. This port's
// analogue: a chord already bound to an acdream-only action
// with no DAT row at all (Ctrl+M mute, the debug F-keys, ...) —
// OpenCantOverwriteBindingDialog's ported refusal, no dialog.
bindings.DisplaySystemMessage(bindings.NonBindableRefusalText);
// OpenCantOverwriteBindingDialog @ 0x00489300: exact
// ID_ActionKeyMap_NonUserBindableBinding(KEY) text in a
// type-3 priority message dialog on queue 0x10000001.
string? refusal = bindings.ResolveTemplate(
"ID_ActionKeyMap_NonUserBindableBinding",
new Dictionary<uint, string> { [KeyVariable] = _describe(chord) });
if (refusal is not null)
bindings.ShowMessage(refusal);
return;
case ConflictOutcome.Rows:
// M3: retail's OpenOverwriteBindingDialog — confirm BEFORE
// reassigning (N-way: every conflicting row is named, not just
// the first). Only on accept do the losing rows lose the slot.
string names = string.Join(", ", conflictRows.Select(r => r.Label ?? "?"));
string message =
$"'{_describe(chord)}' is already bound to {names}. "
+ $"Reassign it to '{view.Label}'?";
string? message = ComposeOverwriteMessage(chord, conflictRows, bindings);
if (message is null)
return;
bindings.ConfirmOverwrite(message, accepted =>
{
if (!accepted) return;
@ -593,13 +654,76 @@ public sealed class KeyboardConfigController
return;
}
});
ArmCapture();
}
private static bool IsUnsupportedRetailCapture(KeyChord chord)
{
if (chord.Device > 1)
return true; // joystick/unknown device
if (chord.Device == 1
&& (chord.Key == InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Left)
|| chord.Key == InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Right)))
return true;
return !RetailScanCodeMap.TryToFileControl(chord, out _);
}
private string? ComposeOverwriteMessage(
KeyChord chord,
IReadOnlyList<RowView> conflicts,
Bindings bindings)
{
string keyName = _describe(chord);
if (conflicts.Count == 1)
{
string? action = conflicts[0].Label;
if (action is null) return null;
return bindings.ResolveTemplate(
"ID_ActionKeyMap_OverwriteExistingBinding",
new Dictionary<uint, string>
{
[KeyVariable] = keyName,
[ActionVariable] = action,
});
}
var lines = new List<string>(conflicts.Count);
foreach (RowView conflict in conflicts)
{
if (conflict.Label is null) return null;
string? line = bindings.ResolveTemplate(
"ID_ActionKeyMap_Binding",
new Dictionary<uint, string>
{
[ActionVariable] = conflict.Label,
[KeyVariable] = keyName,
});
if (line is null) return null;
lines.Add(line);
}
return bindings.ResolveTemplate(
"ID_ActionKeyMap_OverwriteExistingBindings",
new Dictionary<uint, string>
{
[KeyVariable] = keyName,
[BindingsVariable] = string.Join("\n", lines),
});
}
private void ApplySlot(RowView view, int slot, KeyChord chord)
{
List<KeyChord> updated = new(view.Model.Current);
while (updated.Count <= slot) updated.Add(default);
updated[slot] = chord;
// SetBinding @ 0x00487B32..0x00487B47 clamps a requested slot past
// m_qclCurrent.Count to Count. Retail's bindings are a dense list:
// clicking Mapping 3 on an empty row appends at Mapping 1; clicking it
// on a one-binding row appends at Mapping 2.
int targetSlot = Math.Clamp(slot, 0, updated.Count);
if (targetSlot == updated.Count)
updated.Add(chord);
else
updated[targetSlot] = chord;
ReplaceSlotValue(view, updated);
RefreshRowButtons(view);
}
@ -616,13 +740,9 @@ public sealed class KeyboardConfigController
private static void ReplaceSlotValue(RowView view, IReadOnlyList<KeyChord> value)
{
// S4 (2026-08-11 review): only trim TRAILING empty slots. Retail's
// SetBinding(qc, slot) writes the SPECIFIC slot the user clicked — a row
// with no bindings whose "Mapping 3" button is set must keep the chord at
// display index 2, not collapse it onto index 0. Interior default(KeyChord)
// entries only ever come from ApplySlot's own padding, so trimming just the
// tail keeps RefreshRowButtons' positional read correct without inventing a
// nullable-chord storage type.
// The production path is dense (ApplySlot clamps to Count and erase
// removes an element). Keep the trailing-default trim as a defensive
// boundary for compatibility stores created by older schema versions.
int lastReal = -1;
for (int i = 0; i < value.Count; i++)
if (value[i] != default) lastReal = i;
@ -639,9 +759,8 @@ public sealed class KeyboardConfigController
/// <c>ICIDM::FindConflictingInputMaps</c>/<c>FindConflictingControls</c>),
/// scoped to this screen's own universe: the non-user-bindable check runs
/// FIRST (S1 — retail's own order), then EVERY OTHER row's current chord set
/// (covers BOTH mapped and unmapped rows — a chord already claimed by an
/// unmapped row is just as real a conflict as one claimed by a mapped one) is
/// collected in full, not just the first match.
/// (all 306 installed EoR rows are mapped) is collected in full, not just
/// the first match.
/// </summary>
private (ConflictOutcome Outcome, List<RowView> Rows) FindConflicts(KeyChord chord, RowView exclude)
{
@ -659,15 +778,20 @@ public sealed class KeyboardConfigController
foreach (RowView other in _rows)
{
if (ReferenceEquals(other, exclude)) continue;
// OP8 re-review round 2 R1: store-only rows (MappedAction null —
// the Camera Alternate scheme, Emote/CharacterSettings hotkeys)
// never reach the InputDispatcher, so a chord they display cannot
// actually collide with anything; counting them made the ten
// arrow-key defaults trip a false N-way confirm on any arrow
// rebind. Retail-mapped cross-context sharing (ConflictingMaps —
// the Insert/Delete/End/PageUp/PageDown combat cluster) remains
// deferred as ISSUES #373; only INERT rows are excluded here.
// A future unknown-DAT row never reaches the dispatcher, so its
// display-only chord cannot create a live conflict. #373: mapped
// cross-context sharing consults the
// installed DAT's ActionMap.ConflictingMaps table. In particular,
// the melee/missile/magic contexts legitimately share the retail
// Insert/Delete/End/PageUp/PageDown cluster and must not erase one
// another.
if (other.MappedAction is null) continue;
if (_snapshot?.InputMapsConflict(
exclude.InputMapId,
other.InputMapId) != true)
{
continue;
}
if (other.Model.Current.Contains(chord))
rows.Add(other);
}
@ -677,24 +801,42 @@ public sealed class KeyboardConfigController
private static void WireScreenButtons(
ImportedLayout layout, KeyboardConfigController controller, Bindings bindings)
{
// Load File / Save As — INERT (D4: keybinds.json only, no .keymap
// interchange). Authored, clickable, no handler — same shape as OP3's
// still-inert buttons.
_ = layout.FindElement(LoadButtonId);
_ = layout.FindElement(SaveAsButtonId);
_ = layout.FindElement(FilenameLabelId);
UiText? filename = layout.FindElement(FilenameLabelId) as UiText;
void RefreshFilename()
{
if (filename is null || bindings.CurrentKeymapFilename is null) return;
string value = bindings.CurrentKeymapFilename();
filename.LinesProvider = () =>
new[] { new UiText.Line(value, filename.DefaultColor) };
}
RefreshFilename();
if (layout.FindElement(LoadButtonId) is UiButton loadButton
&& bindings.OpenLoadKeymap is { } openLoad)
{
loadButton.OnClick = () => openLoad(() =>
{
controller.ReloadRowsFromBindings(bindings);
RefreshFilename();
});
}
if (layout.FindElement(SaveAsButtonId) is UiButton saveAsButton
&& bindings.OpenSaveKeymap is { } openSave)
{
saveAsButton.OnClick = () => openSave(RefreshFilename);
}
if (layout.FindElement(DefaultsButtonId) is UiButton defaultsButton)
defaultsButton.OnClick = () =>
{
foreach (RowView row in controller._rows)
row.Model.SetDefaultValue(row.Model.DefaultValue);
controller.Page.Defaults();
foreach (RowView row in controller._rows)
controller.RefreshRowButtons(row);
};
if (layout.FindElement(RevertButtonId) is UiButton revertButton)
{
revertButton.OnClick = () =>
{
controller.Page.Reset();
@ -702,16 +844,29 @@ public sealed class KeyboardConfigController
controller.RefreshRowButtons(row);
};
// OK — right-click release in retail (idMessage 0x19); ported as a plain
// left-click here, matching every other Campaign OP button (the asymmetry
// is authored-input-only — no user-visible affordance differs, since
// retail's own right-click-release on just this pair of buttons carries
// no distinguishing visual cue either).
// gmKeyboardUI::OnOptionChanged @ 0x004DA890 addresses the
// m_pKeyboardRevertToSavedButton slot through the secondary
// IOptionChangeHandler base. It is Normal (state 1) exactly while
// OptionPage::Changed is true, otherwise Ghosted (state 0xD).
controller.Page.OnOptionChanged = () =>
revertButton.Enabled = controller.Page.Changed;
controller.Page.OnOptionChanged();
}
// gmKeyboardUI::ListenToElementMessage @ 0x004DD230 handles the
// authored button action/release message (id 0x19, parameter 7). That
// is the ordinary retained-button click path, not evidence of a
// special right-click gesture.
if (layout.FindElement(OkButtonId) is UiButton okButton)
okButton.OnClick = () =>
{
bool changed = controller.Page.Changed;
// Retail only rewrites the active keymap when at least one
// row differs; SaveCurrentValues still advances the Revert
// baseline unconditionally.
if (changed)
bindings.Save();
controller.Page.Apply();
bindings.Save();
bindings.Toggle();
};
@ -724,4 +879,17 @@ public sealed class KeyboardConfigController
bindings.Toggle();
};
}
private void ReloadRowsFromBindings(Bindings bindings)
{
foreach (RowView row in _rows)
{
IReadOnlyList<KeyChord> chords = row.MappedAction is { } action
? bindings.CurrentForAction(action).Select(static value => value.Chord).ToArray()
: bindings.CurrentForUnmapped((row.InputMapId, row.ActionId));
row.Model.ReloadCurrentAndSaved(chords);
RefreshRowButtons(row);
}
Page.OnOptionChanged?.Invoke();
}
}

View file

@ -140,6 +140,12 @@ public sealed class MapHousePanelController : IRetainedPanelController
public bool IsShowingHouse => _tabPanel.ActivePageElementId == HousePageId;
public bool IsShowingMap => _tabPanel.ActivePageElementId == MapPageId;
public void ShowMap() => _tabPanel.SwitchTo(MapPageId);
public void ShowHouse() => _tabPanel.SwitchTo(HousePageId);
public void OnShown()
{
_visible = true;

View file

@ -554,10 +554,10 @@ public sealed class ActionKeyMapOptionRow : IOptionRow
public bool Changed => !_current.SequenceEqual(_saved);
/// <summary>Reset-to-Defaults reloads the DAT master maps fresh
/// (<c>gmKeyboardUI::RestoreDefaultValues</c> — research doc §5.6) before
/// restoring each row, so the default slot list itself can change between
/// presses (a fresh DAT read), not just at construction time.</summary>
/// <summary>Replaces the DAT master-map default used by the next
/// Reset-to-Defaults operation. The installed DAT is immutable during one
/// client process, so the keyboard controller normally seeds this once
/// when it builds the row.</summary>
public void SetDefaultValue(IReadOnlyList<KeyChord> value) => _default = value;
/// <summary>The capture/erase entry point — writes <c>m_current</c> and applies
@ -574,6 +574,16 @@ public sealed class ActionKeyMapOptionRow : IOptionRow
public void SaveCurrentValue() => _saved = _current;
/// <summary>Re-seeds both the live value and Revert baseline after retail's
/// Load File swaps the dispatcher keymap. The dispatcher has already applied
/// the profile, so this intentionally does not call the row's write-back.</summary>
public void ReloadCurrentAndSaved(IReadOnlyList<KeyChord> value)
{
_current = value;
_saved = value;
_notifyPageOptionChanged?.Invoke();
}
public void RestoreSavedValue()
{
_current = _saved;

View file

@ -134,6 +134,28 @@ public sealed class OptionsPanelController : IRetainedPanelController
public OptionPage ConfigPage => _pages[ConfigPageId];
/// <summary>True when the authored Gameplay Options page is active.</summary>
public bool IsShowingGameplay =>
_tabPanel.ActivePageElementId == GameplayPageId;
public bool IsShowingCharacter =>
_tabPanel.ActivePageElementId == CharacterPageId;
public bool IsShowingConfiguration =>
_tabPanel.ActivePageElementId == ConfigPageId;
/// <summary>
/// Programmatic form of retail action <c>0x1000001B</c>, resolved from
/// the installed ActionMap as "Show/Hide Gameplay Options Page". This is
/// the final fallback of <c>ClientUISystem::OnAction(EscapeKey)</c> at
/// <c>0x00564CBF</c>.
/// </summary>
public void ShowGameplay() => _tabPanel.SwitchTo(GameplayPageId);
public void ShowCharacter() => _tabPanel.SwitchTo(CharacterPageId);
public void ShowConfiguration() => _tabPanel.SwitchTo(ConfigPageId);
private OptionsPanelController(UiTabPanel tabPanel, Action? afterApply)
{
_tabPanel = tabPanel;

View file

@ -115,6 +115,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
_objects.ObjectUpdated += OnObjectChanged;
_objects.Cleared += OnObjectsCleared;
_selection.Changed += OnSelectionChanged;
_itemInteraction.StateChanged += OnInteractionStateChanged;
// ── Slots-toggle wiring ───────────────────────────────────────────────────────────────────
foreach (var id in ArmorSlotElementIds)
@ -216,6 +217,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
Populate();
}
private void OnSelectionChanged(SelectionTransition _) => ApplySelectionIndicators();
private void OnInteractionStateChanged() => Populate();
private void OnObjectsCleared()
{
ApplyAetheriaVisibility();
@ -225,8 +227,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
/// <summary>The object belongs to the player (wielded gear or pack contents) — so a change to it may
/// add/remove/repaint a doll slot. Player-scoped: an NPC's or vendor's wielded item (which also carries
/// CurrentlyEquippedLocation from the wire) must NOT trigger a repaint. A player-equipped item always
/// has WielderId==p (login, from CreateObject) or ContainerId==p (live/optimistic wield, set by
/// WieldItemOptimistic), so the equip-location need not be tested here; OnObjectMoved carries the
/// has WielderId==p (login, from CreateObject) or ContainerId==p, so the
/// equip-location need not be tested here; OnObjectMoved carries the
/// complete old/new retail placement for transitions that satisfy neither after mutation.</summary>
private bool Concerns(ClientObject o)
{
@ -256,6 +258,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
uint dragTex = _dragIconIds?.Invoke(
worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects) ?? 0u;
list.Cell.SetItem(worn.ObjectId, tex, dragIconTexture: dragTex);
list.Cell.SetWaitingState(
_itemInteraction.IsPendingInventorySource(worn.ObjectId));
}
ApplyAetheriaVisibility();
ApplySelectionIndicators();
@ -278,7 +282,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
foreach (var (_, list) in _slots)
{
list.Cell.Selected = list.Cell.ItemId != 0
&& list.Cell.ItemId == _selection.SelectedObjectId;
&& list.Cell.ItemId == _selection.SelectedObjectId
&& !_itemInteraction.IsPendingInventorySource(list.Cell.ItemId);
}
}
@ -369,6 +374,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
_objects.ObjectUpdated -= OnObjectChanged;
_objects.Cleared -= OnObjectsCleared;
_selection.Changed -= OnSelectionChanged;
_itemInteraction.StateChanged -= OnInteractionStateChanged;
foreach (var (_, list) in _slots)
{
list.PrimaryItemPressed = null;

View file

@ -0,0 +1,122 @@
using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
/// <summary>Retail type-7 <c>ConfirmationMenuDialog</c>, used by Configure
/// Keyboard's authored Load File button.</summary>
internal sealed class RetailConfirmationMenuDialogView : IRetailDialogView
{
public const uint RootElementId = 0x1Fu;
public const uint MenuElementId = 0x21u;
public const uint AcceptButtonId = 0x22u;
public const uint RejectButtonId = 0x23u;
public const uint PopupElementId = 0x3Du;
private readonly UiRoot _host;
private readonly RetailDialogData _data;
private readonly uint _context;
private readonly Action<uint> _closeDialog;
private readonly UiElement? _popup;
private readonly UiMenu _menu;
private readonly UiButton _accept;
private readonly UiButton _reject;
public RetailConfirmationMenuDialogView(
UiRoot host,
ImportedLayout layout,
RetailDialogData data,
uint context,
Action<uint> closeDialog)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
ArgumentNullException.ThrowIfNull(layout);
_data = data ?? throw new ArgumentNullException(nameof(data));
_context = context;
_closeDialog = closeDialog ?? throw new ArgumentNullException(nameof(closeDialog));
Root = layout.Root as UiDialogRoot
?? throw new ArgumentException(
"Confirmation-menu layout root is not a UiDialogRoot.", nameof(layout));
_popup = layout.FindElement(PopupElementId);
_menu = layout.FindElement(MenuElementId) as UiMenu
?? throw new ArgumentException(
"Confirmation-menu layout is missing menu element 0x21.", nameof(layout));
_accept = layout.FindElement(AcceptButtonId) as UiButton
?? throw new ArgumentException(
"Confirmation-menu layout is missing accept button 0x22.", nameof(layout));
_reject = layout.FindElement(RejectButtonId) as UiButton
?? throw new ArgumentException(
"Confirmation-menu layout is missing reject button 0x23.", nameof(layout));
IReadOnlyList<string> items = _data.TryGet<string[]>(
RetailDialogProperty.MenuItems, out string[] values)
? values
: Array.Empty<string>();
_menu.Items = items.Select(
static (label, index) => new UiMenu.MenuItem(label, index)).ToArray();
int selected = Math.Clamp(
_data.GetInt32(RetailDialogProperty.MenuSelection),
0,
Math.Max(0, items.Count - 1));
_menu.Selected = items.Count == 0 ? null : selected;
_menu.OnSelect = payload => _menu.Selected = payload;
_menu.ButtonLabelProvider = () =>
_menu.Selected is int index && index >= 0 && index < items.Count
? items[index]
: string.Empty;
if (_data.GetString(RetailDialogProperty.MenuAcceptLabel) is { } acceptLabel)
_accept.Label = acceptLabel;
if (_data.GetString(RetailDialogProperty.MenuRejectLabel) is { } rejectLabel)
_reject.Label = rejectLabel;
Root.Cancel = Reject;
_accept.OnClick = Accept;
_reject.OnClick = Reject;
SizeAndCenter();
}
public UiDialogRoot Root { get; }
public void Tick() => SizeAndCenter();
public void SetPendingCount(int count)
{
}
public void DetachHandlers()
{
Root.Cancel = null;
_accept.OnClick = null;
_reject.OnClick = null;
_menu.OnSelect = null;
}
private void Accept()
{
_data.Set(
RetailDialogProperty.MenuSelection,
_menu.Selected is int selected ? selected : -1);
_closeDialog(_context);
}
private void Reject()
{
_data.Set(RetailDialogProperty.MenuSelection, -1);
_closeDialog(_context);
}
private void SizeAndCenter()
{
var space = _host.EffectiveCanvasSize;
Root.Left = 0f;
Root.Top = 0f;
Root.Width = space.X;
Root.Height = space.Y;
if (_popup is null) return;
_popup.LayoutPolicy = null;
_popup.Anchors = AnchorEdges.None;
_popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f);
_popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f);
}
}

View file

@ -14,6 +14,11 @@ public static class RetailDialogProperty
public const uint TextInputAcceptLabel = 0x9Au;
public const uint TextInputRejectLabel = 0x9Bu;
public const uint TextInputResult = 0x9Cu;
public const uint MenuItems = 0xA6u;
public const uint MenuItem = 0xA7u;
public const uint MenuAcceptLabel = 0xA8u;
public const uint MenuRejectLabel = 0xA9u;
public const uint MenuSelection = 0xABu;
/// <summary>
/// When true, <c>Dialog::SetData @ 0x00476BE0</c> sets UIElement boolean
/// attribute <c>0x40</c>. The Keystone-owned attribute name is unavailable.
@ -97,6 +102,19 @@ public sealed class RetailDialogData
}
: defaultValue;
public int GetInt32(uint propertyId, int defaultValue = 0)
=> _values.TryGetValue(propertyId, out object? raw)
? raw switch
{
byte value => value,
ushort value => value,
int value => value,
uint value when value <= int.MaxValue => (int)value,
Enum value => Convert.ToInt32(value),
_ => defaultValue,
}
: defaultValue;
public string? GetString(uint propertyId)
=> _values.TryGetValue(propertyId, out object? raw) ? raw as string : null;
@ -148,4 +166,18 @@ public sealed class RetailDialogData
.Set(RetailDialogProperty.ElementAttribute40, true)
.Set(RetailDialogProperty.Message, message);
}
/// <summary>Type-7 confirmation menu used by retail's keyboard-profile
/// Load File workflow (<c>gmKeyboardUI::MakeLoadKeymapDialog</c>).</summary>
public static RetailDialogData ConfirmationMenu(
IReadOnlyList<string> items,
int selectedIndex = 0)
{
ArgumentNullException.ThrowIfNull(items);
return new RetailDialogData()
.Set(RetailDialogProperty.Type, RetailDialogType.ConfirmationMenu)
.Set(RetailDialogProperty.ElementAttribute40, true)
.Set(RetailDialogProperty.MenuItems, items.ToArray())
.Set(RetailDialogProperty.MenuSelection, selectedIndex);
}
}

View file

@ -169,20 +169,28 @@ public sealed class RetailDialogFactory : IDisposable
/// <c>UIOption_ActionKeyMap::OpenMapWarnDialog @ 0x00488A00</c>: type 2,
/// caller-chosen queue key, element attribute 0x40 set, message text.
/// </summary>
public uint MakeWait(string message, uint queueKey = DefaultQueueKey)
public uint MakeWait(
string message,
uint queueKey = DefaultQueueKey,
bool priority = false)
{
RetailDialogData data = RetailDialogData.Wait(message)
.Set(RetailDialogProperty.QueueKey, queueKey);
if (priority)
data.Set(RetailDialogProperty.Priority, true);
return MakeDialog(data, callback: null);
}
public uint MakeMessage(
string message,
Action<RetailDialogData>? callback = null,
uint queueKey = DefaultQueueKey)
uint queueKey = DefaultQueueKey,
bool priority = false)
{
RetailDialogData data = RetailDialogData.Message(message)
.Set(RetailDialogProperty.QueueKey, queueKey);
if (priority)
data.Set(RetailDialogProperty.Priority, true);
return MakeDialog(data, callback);
}
@ -196,6 +204,17 @@ public sealed class RetailDialogFactory : IDisposable
return MakeDialog(data, callback);
}
public uint MakeConfirmationMenu(
IReadOnlyList<string> items,
int selectedIndex,
Action<RetailDialogData>? callback = null,
uint queueKey = DefaultQueueKey)
{
RetailDialogData data = RetailDialogData.ConfirmationMenu(items, selectedIndex)
.Set(RetailDialogProperty.QueueKey, queueKey);
return MakeDialog(data, callback);
}
/// <summary>
/// Retail <c>CloseDialog @ 0x00478160</c>. The context can identify an active
/// nonqueued dialog, an active queued dialog, or an item still pending in a queue.
@ -395,7 +414,8 @@ public sealed class RetailDialogFactory : IDisposable
if (type is not (RetailDialogType.Confirmation
or RetailDialogType.Wait
or RetailDialogType.Message
or RetailDialogType.ConfirmationTextInput))
or RetailDialogType.ConfirmationTextInput
or RetailDialogType.ConfirmationMenu))
{
throw new NotSupportedException(
$"Retail dialog type {(uint)type} does not have a ported presenter yet.");
@ -415,6 +435,10 @@ public sealed class RetailDialogFactory : IDisposable
new RetailConfirmationTextInputDialogView(
_host, layout, info.Data, info.Context,
context => CloseDialog(context)),
RetailDialogType.ConfirmationMenu =>
new RetailConfirmationMenuDialogView(
_host, layout, info.Data, info.Context,
context => CloseDialog(context)),
_ => new RetailConfirmationDialogView(
_host, layout, info.Data, info.Context,
context => CloseDialog(context)),

View file

@ -37,8 +37,7 @@ namespace AcDream.App.UI.Layout;
/// / DIK_LMENU), names its METAKEY through the meta table + OS fallback and
/// joins with the authored <c>ID_KeyDescDelimiter</c> ("+", table enum 3 →
/// DID <c>0x23000007</c>). A binding whose KEY IS a modifier key (retail's
/// walk-mode DIK_LSHIFT row has meta-mode 0; acdream's <see cref="KeyChord"/>
/// carries the wire-side self-modifier bit) shows only the key name — never
/// walk-mode DIK_LSHIFT row has meta-mode 0) shows only the key name — never
/// "Shift+ShiftLeft".
/// </para>
/// </summary>
@ -79,21 +78,34 @@ public sealed class RetailKeyNames
/// <summary>
/// Display name for one bound chord — retail
/// <c>GetNameFromKey(QualifiedControl)</c>. Mouse chords keep the
/// pre-existing enum spelling: retail names mouse controls through the
/// DirectInput mouse device, which this port does not have (AD-95a).
/// <c>GetNameFromKey(QualifiedControl)</c>. Mouse controls use retail's
/// DIMOFS semantic/table lookup. If the table misses, DirectInput would
/// provide a localized object name; acdream's non-DirectInput fallback is
/// the stable user-facing "Mouse Button N".
/// </summary>
public string Describe(KeyChord chord)
{
if (chord == default)
return string.Empty;
if (TryGetMouseSemantic(chord, out string? mouseSemantic, out int buttonNumber))
{
string mouseName = _resolveString(
KeyNameTableId,
DatStringResolver.ComputeHash(mouseSemantic!))
?? $"Mouse Button {buttonNumber}";
return Compose(chord, mouseName);
}
if (!TryGetDik(chord.Key, out byte dik, out string? dikName))
return FallbackSpelling(chord);
return Compose(chord, LookupName(dikName!, dik, KeyNameTableId));
}
private string Compose(KeyChord chord, string keyName)
{
var composed = new System.Text.StringBuilder();
// Meta-mode bits ascending, skipping the key's own self-modifier bit
// (retail's walk-mode LSHIFT row carries meta-mode 0 on the wire; the
// chord's stored self bit is acdream's encoding, not display truth).
// (retail's walk-mode LSHIFT row carries meta-mode 0 on the wire).
foreach ((ModifierMask flag, Key metaKey) in MetaOrder)
{
if ((chord.Modifiers & flag) == 0 || IsSelfModifier(chord.Key, flag))
@ -104,10 +116,36 @@ public sealed class RetailKeyNames
composed.Append(_delimiter);
}
composed.Append(LookupName(dikName!, dik, KeyNameTableId));
composed.Append(keyName);
return composed.ToString();
}
private static bool TryGetMouseSemantic(
KeyChord chord,
out string? semantic,
out int buttonNumber)
{
int zeroBased = (int)chord.Key switch
{
-1001 => 0,
-1002 => 1,
-1003 => 2,
-1004 => 3,
-1005 => 4,
_ => -1,
};
if (chord.Device != 1 || zeroBased < 0)
{
semantic = null;
buttonNumber = 0;
return false;
}
semantic = $"DIMOFS_BUTTON{zeroBased}";
buttonNumber = zeroBased + 1;
return true;
}
private string LookupName(string dikName, byte dik, uint tableId)
=> _resolveString(tableId, DatStringResolver.ComputeHash(dikName))
?? _osKeyName?.Invoke((byte)(dik & 0x7F), (dik & 0x80) != 0)
@ -126,6 +164,7 @@ public sealed class RetailKeyNames
(ModifierMask.Shift, Key.ShiftLeft),
(ModifierMask.Ctrl, Key.ControlLeft),
(ModifierMask.Alt, Key.AltLeft),
(ModifierMask.Win, Key.SuperLeft),
};
private static bool IsSelfModifier(Key key, ModifierMask flag)
@ -134,15 +173,15 @@ public sealed class RetailKeyNames
ModifierMask.Shift => key is Key.ShiftLeft or Key.ShiftRight,
ModifierMask.Ctrl => key is Key.ControlLeft or Key.ControlRight,
ModifierMask.Alt => key is Key.AltLeft or Key.AltRight,
ModifierMask.Win => key is Key.SuperLeft or Key.SuperRight,
_ => false,
};
/// <summary>
/// Silk key → DirectInput scan code + DIK name — the reverse of
/// <see cref="RetailScanCodeMap.ToSilkKey"/>'s keyboard table (same 84
/// DAT-observed codes) plus the modifier keys live capture can produce
/// that no DAT default binds directly (DIK_LCONTROL 0x1D, DIK_LMENU 0x38,
/// DIK_RMENU 0xB8). DIK codes with bit 0x80 are the extended set — the
/// <see cref="RetailScanCodeMap.ToSilkKey"/>'s keyboard table: the 84
/// DAT-default codes plus the additional controls accepted by retail's
/// plain-text keymap format. DIK codes with bit 0x80 are the extended set — the
/// same split Win32's GetKeyNameText expects in bit 24.
/// </summary>
private static bool TryGetDik(Key key, out byte dik, out string? name)
@ -206,6 +245,7 @@ public sealed class RetailKeyNames
Key.KeypadMultiply => ((byte)0x37, "DIK_MULTIPLY"),
Key.AltLeft => ((byte)0x38, "DIK_LMENU"),
Key.Space => ((byte)0x39, "DIK_SPACE"),
Key.CapsLock => ((byte)0x3A, "DIK_CAPITAL"),
Key.F1 => ((byte)0x3B, "DIK_F1"),
Key.F2 => ((byte)0x3C, "DIK_F2"),
Key.F3 => ((byte)0x3D, "DIK_F3"),
@ -233,10 +273,15 @@ public sealed class RetailKeyNames
Key.KeypadDecimal => ((byte)0x53, "DIK_DECIMAL"),
Key.F11 => ((byte)0x57, "DIK_F11"),
Key.F12 => ((byte)0x58, "DIK_F12"),
Key.F13 => ((byte)0x64, "DIK_F13"),
Key.F14 => ((byte)0x65, "DIK_F14"),
Key.F15 => ((byte)0x66, "DIK_F15"),
Key.KeypadEnter => ((byte)0x9C, "DIK_NUMPADENTER"),
Key.ControlRight => ((byte)0x9D, "DIK_RCONTROL"),
Key.KeypadDivide => ((byte)0xB5, "DIK_DIVIDE"),
Key.PrintScreen => ((byte)0xB7, "DIK_SYSRQ"),
Key.AltRight => ((byte)0xB8, "DIK_RMENU"),
Key.Pause => ((byte)0xC5, "DIK_PAUSE"),
Key.Home => ((byte)0xC7, "DIK_HOME"),
Key.Up => ((byte)0xC8, "DIK_UP"),
Key.PageUp => ((byte)0xC9, "DIK_PRIOR"),
@ -247,6 +292,9 @@ public sealed class RetailKeyNames
Key.PageDown => ((byte)0xD1, "DIK_NEXT"),
Key.Insert => ((byte)0xD2, "DIK_INSERT"),
Key.Delete => ((byte)0xD3, "DIK_DELETE"),
Key.SuperLeft => ((byte)0xDB, "DIK_LWIN"),
Key.SuperRight => ((byte)0xDC, "DIK_RWIN"),
Key.Menu => ((byte)0xDD, "DIK_APPS"),
_ => ((byte)0, null),
};
return name is not null;

View file

@ -94,6 +94,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
private readonly StackSplitQuantityState _splitQuantity;
private readonly SelectionState _selection;
private readonly Func<uint, bool> _isVendorSplitExempt;
private readonly Func<uint, bool> _isCoinstack;
private readonly Func<int> _coinTotal;
private readonly Action<Action<uint, float>> _unsubscribeHealthChanged;
private readonly Action<Action<uint, float, bool>> _unsubscribeItemManaChanged;
private readonly Action<Action<ClientObject>> _unsubscribeObjectUpdated;
@ -128,7 +130,9 @@ public sealed class SelectedObjectController : IRetainedPanelController
StackSplitQuantityState splitQuantity,
Action<Action<ClientObject>> subscribeObjectUpdated,
Action<Action<ClientObject>> unsubscribeObjectUpdated,
Func<uint, bool> isVendorSplitExempt)
Func<uint, bool> isVendorSplitExempt,
Func<uint, bool>? isCoinstack,
Func<int>? coinTotal)
{
_isHealthTarget = isHealthTarget;
_isOwnedByPlayer = isOwnedByPlayer;
@ -143,6 +147,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_isVendorSplitExempt = isVendorSplitExempt
?? throw new ArgumentNullException(nameof(isVendorSplitExempt));
_isCoinstack = isCoinstack ?? (_ => false);
_coinTotal = coinTotal ?? (() => 0);
_unsubscribeHealthChanged = unsubscribeHealthChanged;
_unsubscribeItemManaChanged = unsubscribeItemManaChanged;
_unsubscribeObjectUpdated = unsubscribeObjectUpdated;
@ -319,7 +325,9 @@ public sealed class SelectedObjectController : IRetainedPanelController
StackSplitQuantityState splitQuantity,
Action<Action<ClientObject>> subscribeObjectUpdated,
Action<Action<ClientObject>> unsubscribeObjectUpdated,
Func<uint, bool> isVendorSplitExempt)
Func<uint, bool> isVendorSplitExempt,
Func<uint, bool>? isCoinstack = null,
Func<int>? coinTotal = null)
=> new SelectedObjectController(
layout, selection,
subscribeHealthChanged, unsubscribeHealthChanged,
@ -327,7 +335,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
isHealthTarget, isOwnedByPlayer, name, healthPercent, hasHealth, stackSize,
sendQueryHealth, manaPercent, sendQueryItemMana, datFont,
splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated,
isVendorSplitExempt);
isVendorSplitExempt, isCoinstack, coinTotal);
/// <summary>
/// Port of <c>gmToolbarUI::HandleSelectionChanged</c> (<c>:198635</c>):
@ -373,9 +381,11 @@ public sealed class SelectedObjectController : IRetainedPanelController
// ── 2. Name (displayed via the UiText child's LinesProvider reading _currentName). ──
uint stackSize = _stackSize(g);
string? objectName = _resolveName(g);
_currentName = stackSize > 1u && !string.IsNullOrEmpty(objectName)
? $"{stackSize} {objectName}"
: objectName;
_currentName = _isCoinstack(g) && _isOwnedByPlayer(g)
? $"{stackSize} {objectName} (of {_coinTotal()})"
: stackSize > 1u && !string.IsNullOrEmpty(objectName)
? $"{stackSize} {objectName}"
: objectName;
// ── 3. Selection overlay: brief flash (retail container ObjectSelected
// = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ──────────────
@ -522,6 +532,26 @@ public sealed class SelectedObjectController : IRetainedPanelController
}
}
/// <summary>
/// Retail <c>gmToolbarUI::RecvNotice_SplitStack @ 0x004BD2A0</c>: when
/// the notice still names the selected stack and its size is greater than
/// one, focus the numeric quantity field and select all of its text.
/// </summary>
public bool FocusSplitStackEntry(uint objectId)
{
if (_current != objectId
|| _stackSize(objectId) <= 1u
|| _stackSizeEntry is null
|| !_stackSizeEntry.Visible)
{
return false;
}
_stackSizeEntry.FindRoot()?.SetKeyboardFocus(_stackSizeEntry);
_stackSizeEntry.SelectAllText();
return true;
}
private void OnObjectUpdated(ClientObject updated)
{
if (_current == updated.ObjectId && _stackSize(updated.ObjectId) != _splitQuantity.Maximum)

View file

@ -243,6 +243,8 @@ public sealed class SocialPanelController : IRetainedPanelController
/// <summary>F4 <c>ToggleFellowshipPanel</c>'s tab-switch half.</summary>
public void ShowFellowship() => _tabPanel.SwitchTo(FellowshipPageId);
public void ShowFriends() => _tabPanel.SwitchTo(FriendsPageId);
/// <summary>True when the Allegiance tab is the active page — lets
/// <see cref="RetailUiRuntime.HandleInputAction"/> implement the
/// close-on-second-press-of-the-SAME-tab semantics every other
@ -264,6 +266,8 @@ public sealed class SocialPanelController : IRetainedPanelController
/// <summary>True when the Fellowship tab is the active page.</summary>
public bool IsShowingFellowship => _tabPanel.ActivePageElementId == FellowshipPageId;
public bool IsShowingFriends => _tabPanel.ActivePageElementId == FriendsPageId;
/// <summary>True while the social panel's own window is shown — set by
/// <see cref="OnShown"/>/<see cref="OnHidden"/>. Fix-round blast SF-2:
/// gates the Friends/Squelch rebuild (see <see cref="Tick"/>) so their

View file

@ -216,9 +216,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
public bool Handle(InputAction action)
{
if (action is >= InputAction.UseSpellSlot_1 and <= InputAction.UseSpellSlot_9)
if (TryMapSpellShortcut(action, out int index))
{
int index = (int)action - (int)InputAction.UseSpellSlot_1;
IReadOnlyList<uint> spells = _spellbook.GetFavorites(_activeTab);
if (index < spells.Count)
{
@ -243,6 +242,27 @@ public sealed class SpellcastingUiController : IRetainedPanelController
}
}
internal static bool TryMapSpellShortcut(
InputAction action,
out int index)
{
if (action is >= InputAction.UseSpellSlot_1
and <= InputAction.UseSpellSlot_9)
{
index = (int)action - (int)InputAction.UseSpellSlot_1;
return true;
}
index = action switch
{
InputAction.UseSpellSlot_10 => 9,
InputAction.UseSpellSlot_11 => 10,
InputAction.UseSpellSlot_12 => 11,
_ => -1,
};
return index >= 0;
}
private void SelectTab(int tab)
{
_activeTab = Math.Clamp(tab, 0, 7);

View file

@ -56,6 +56,23 @@ public sealed class ToolbarInputController
return true;
}
if (action is InputAction.UseQuickSlot_10
or InputAction.UseQuickSlot_11
or InputAction.UseQuickSlot_12
or InputAction.UseQuickSlot_13)
{
slot = action switch
{
InputAction.UseQuickSlot_10 => 9,
InputAction.UseQuickSlot_11 => 10,
InputAction.UseQuickSlot_12 => 11,
InputAction.UseQuickSlot_13 => 12,
_ => -1,
};
use = true;
return true;
}
if (value >= (int)InputAction.UseQuickSlot_14
&& value <= (int)InputAction.UseQuickSlot_18)
{

View file

@ -381,10 +381,18 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// X-close confirmation is already up; HandleButtonClicks' 0x100000d6
// case only opens a NEW one when this is 0 (pc:204155).
private uint _closeConfirmContext;
private int _lastAlternateCurrencyPurchase;
private bool _alternateCurrencyInventoryObserved;
private PendingVendorSplit? _pendingVendorSplit;
// F5: see DragOverGlobalTimeSink's own doc comment.
private readonly DragOverGlobalTimeSink _dragOverSink;
private bool _disposed;
private readonly record struct PendingVendorSplit(
uint SourceGuid,
uint WeenieClassId,
int Quantity);
private VendorUiController(
VendorState vendor,
RetailWindowHandle window,
@ -499,6 +507,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// succeeds and this stops being a dead end — closes half of AP-161
// finding #2.
_itemList.ExamineItemRequested = ExamineItem;
_itemList.PrimaryItemPressed = PressVendorItem;
if (itemScrollbar is not null)
{
itemScrollbar.Model = _itemList.Scroll;
@ -528,6 +537,16 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// gate (pc:204229-204246) — the Selling tab's list is the ONLY drop
// target. UiItemList.RegisterDragHandler is the structural analogue.
_sellingList?.RegisterDragHandler(this);
if (_buyingList is not null)
{
_buyingList.PrimaryItemPressed = PressVendorItem;
_buyingList.ExamineItemRequested = ExamineItem;
}
if (_sellingList is not null)
{
_sellingList.PrimaryItemPressed = PressVendorItem;
_sellingList.ExamineItemRequested = ExamineItem;
}
// F5: mount the global-time sink so a live drag hovering anywhere
// over this window auto-switches to the Selling tab — see
@ -637,7 +656,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// separate "staging changed" gate from "holdings changed" (both
// UpdateTotalValue calls read the LIVE holding fresh, same as
// BuildCostText's own PropertyInt.CoinValue read).
_objects.ObjectAdded += OnObjectAdded;
_objects.ObjectUpdated += OnObjectMoneyChanged;
_objects.StackSizeUpdated += OnStackSizeUpdated;
_objects.ObjectMoved += OnObjectMoved;
ShowTab(VendorPanelTab.Items);
ClearContent();
@ -661,6 +683,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// mechanism every other panel already uses, not a vendor-specific
// special case.
_objects.ObjectRemoved += OnObjectRemoved;
_itemInteraction.RuntimeTransactions.Inventory.RequestFailed += OnInventoryRequestFailed;
// Slice 6.3: mirrors ExternalContainerController's own
// _itemInteraction.StateChanged subscription — the Buy button must
// disable the instant a reservation is taken (BeginUseRequestReservation
@ -866,6 +889,14 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
private void ShowTab(VendorPanelTab tab)
{
// gmVendorUI::OpenTab resets m_last_sale. Authoritative inventory
// remains the preferred source; this only clears the optimistic
// post-buy subtraction used before that update arrives.
if (_lastAlternateCurrencyPurchase != 0)
{
_lastAlternateCurrencyPurchase = 0;
RefreshMoneyText();
}
_itemsPage.Visible = tab == VendorPanelTab.Items;
_buyingPage.Visible = tab == VendorPanelTab.Buying;
_sellingPage.Visible = tab == VendorPanelTab.Selling;
@ -894,6 +925,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// staging lists the same way the category selection resets.
_buyStaging.Clear();
_sellStaging.Clear();
_pendingVendorSplit = null;
ResetAlternateCurrencyTracking();
RefreshMoneyText();
_selectedCategoryIndex = -1;
ShowTab(VendorPanelTab.Items);
RebuildCategories();
@ -910,6 +944,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// the time this fires the relevant list is already empty in
// the normal flow, and the OTHER (untouched) list must
// survive a refresh triggered by its sibling.
ResetAlternateCurrencyTracking();
RefreshMoneyText();
ShowTab(VendorPanelTab.Items);
RebuildCategories();
_window.Show();
@ -920,6 +956,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// the session (contract's C2/C3 close semantics).
_buyStaging.Clear();
_sellStaging.Clear();
_pendingVendorSplit = null;
ResetAlternateCurrencyTracking();
ClearContent();
ShowTab(VendorPanelTab.Items);
_window.Hide();
@ -1112,15 +1150,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
cell.SetItem(item.ItemGuid, icon);
cell.Selected = item.ItemGuid == selectedGuid;
VendorShopItem captured = item;
cell.Clicked = () => _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
// AP-171: double-click buys the item — a DELIBERATE,
// user-approved modernization. Retail has NO
// double-click-to-buy anywhere in the named function
// table (negative evidence recorded at the Slice 6
// research); the user requested it explicitly
// 2026-08-08 after being told so. Select-then-buy so
// the quantity/price path is identical to the Buy
// button's.
cell.Clicked = () =>
_selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
// gmVendorUI::HandleMousePresses @ 0x004C40D0: a
// double-click in the browse list calls BuySingleItem.
cell.DoubleClicked = () =>
{
_selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
@ -1252,14 +1285,23 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
SetPlainText(_itemNameText, nameText);
VendorShopProfile profile = _vendor.Profile;
int rawValue = item.Value ?? 0;
int perUnit = VendorPricing.PerUnitValue(rawValue, item.DescStackSize);
int price = VendorPricing.SellPrice(perUnit, item.ItemType ?? 0u, profile.SellPrice, quantity);
int price = ComputeShopItemPrice(item, quantity);
SetPlainText(_itemCostText, BuildCostText(profile, quantity, price));
SetActionButtonsEnabled(true);
}
private int ComputeShopItemPrice(VendorShopItem item, int quantity)
{
int rawValue = item.Value ?? 0;
int perUnit = VendorPricing.PerUnitValue(rawValue, item.DescStackSize);
return VendorPricing.SellPrice(
perUnit,
item.ItemType ?? 0u,
_vendor.Profile.SellPrice,
quantity);
}
/// <summary>
/// Right-click examine on a shop row — mirrors
/// <c>ExternalContainerController.ExamineItem</c>'s "select then
@ -1276,6 +1318,13 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
_itemInteraction.ExamineSelectedOrEnterMode(guid);
}
private bool PressVendorItem(uint guid)
{
if (guid != 0u)
_selection.Select(guid, SelectionChangeSource.Vendor);
return false;
}
/// <summary>
/// Slice 6.2: reacts to ANY global selection change, not just ones this
/// panel originated — mirrors <c>ExternalContainerController.OnSelectionChanged</c>.
@ -1390,6 +1439,15 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// </summary>
private void OnObjectRemoved(ClientObject item)
{
if (IsCurrentAlternateCurrency(item))
{
_alternateCurrencyInventoryObserved = true;
_lastAlternateCurrencyPurchase = 0;
RefreshMoneyText();
}
if (_pendingVendorSplit is { } split && split.SourceGuid == item.ObjectId)
_pendingVendorSplit = null;
if (_selection.SelectedObjectId == item.ObjectId)
{
_selection.Clear(
@ -1463,11 +1521,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// (<c>pc:203494-203497</c>) via the SAME <see cref="ClientObjectTable"/>
/// generic int-property bundle every other PropertyInt-driven display
/// reads. The alt-currency holding is retail's
/// <c>shopVendorProfile-&gt;trade_num - m_last_sale</c>;
/// <c>m_last_sale</c> only changes on a completed Slice-6 purchase, so
/// with no purchase mechanism yet this port uses
/// <see cref="VendorShopProfile.AlternateCurrencyAmount"/> directly
/// (retail's <c>m_last_sale == 0</c> case — see the register, AP-161).
/// <c>shopVendorProfile-&gt;trade_num - m_last_sale</c>. This controller
/// mirrors the immediate subtraction after dispatch and then reconciles
/// to the authoritative player-owned currency stacks when their object
/// updates arrive; the profile amount is only the pre-observation fallback.
/// </para>
/// </summary>
private string BuildCostText(VendorShopProfile profile, int quantity, int price)
@ -1479,7 +1536,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
"This item costs {0} {1}. You have {2} {1}.",
price,
profile.AlternateCurrencyPluralName,
(int)profile.AlternateCurrencyAmount);
ResolveAlternateCurrencyAmount(profile));
}
int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0;
@ -1576,11 +1633,17 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
return;
uint quantity = ResolveBuyQuantity(shopItem);
_itemInteraction.TryBuy(
_vendor.VendorId,
shopItem.ItemGuid,
(int)quantity,
_vendor.Profile.AlternateCurrencyWcid);
VendorShopProfile profile = _vendor.Profile;
if (_itemInteraction.TryBuy(
_vendor.VendorId,
shopItem.ItemGuid,
(int)quantity,
profile.AlternateCurrencyWcid))
{
RecordAlternateCurrencyPurchase(
profile,
ComputeShopItemPrice(shopItem, (int)quantity));
}
}
private bool TryFindShopItem(uint guid, out VendorShopItem shopItem)
@ -1653,6 +1716,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
(int)quantity,
_vendor.Profile.AlternateCurrencyWcid))
{
RecordAlternateCurrencyPurchase(
_vendor.Profile,
ComputeShopItemPrice(shopItem, (int)quantity));
_buyStaging.Remove(shopItem.ItemGuid, BuyStagingRemovalAmount(shopItem));
}
}
@ -1684,11 +1750,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// <list type="number">
/// <item>pyreal affordability — transaction total vs. purse
/// (<c>pc:204017</c>: <c>m_transactionValue &lt;= m_totalValue</c>).</item>
/// <item>alt-currency affordability — vs. held trade currency minus
/// <c>m_last_sale</c> (<c>pc:204032</c>). This session tracks no
/// <c>m_last_sale</c> credit yet (see the register's AP-161 residual),
/// so this uses the vendor's raw held count, retail's own
/// <c>m_last_sale == 0</c> case.</item>
/// <item>alt-currency affordability — vs. the authoritative held trade
/// currency minus <c>m_last_sale</c> (<c>pc:204032</c>).</item>
/// <item>container-slot capacity (<c>pc:204053</c>:
/// <c>containerSlotsNeeded &gt; player.ContainersCapacity - containersUsed</c>).</item>
/// <item>item-slot capacity (<c>pc:204067</c>: the same shape for
@ -1753,7 +1816,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
return;
}
}
else if (transactionValue > (int)profile.AlternateCurrencyAmount)
else if (transactionValue > ResolveAlternateCurrencyAmount(profile))
{
_systemMessage?.Invoke(NotEnoughMoneyMessage);
return;
@ -1778,7 +1841,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
}
if (_itemInteraction.TryBuyAll(_vendor.VendorId, items, profile.AlternateCurrencyWcid))
{
RecordAlternateCurrencyPurchase(profile, transactionValue);
_buyStaging.Clear();
}
}
/// <summary>F1: the SAME per-row price formula <see cref="ApplyItemDisplay"/> shows, summed over every staged entry.</summary>
@ -1904,7 +1970,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
return string.Format(
CultureInfo.InvariantCulture,
"You have {0} {1}.",
(int)profile.AlternateCurrencyAmount,
ResolveAlternateCurrencyAmount(profile),
profile.AlternateCurrencyPluralName);
}
int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0;
@ -1961,8 +2027,52 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// </summary>
private void OnObjectMoneyChanged(ClientObject updated)
{
TryResolvePendingVendorSplit(updated);
if (updated.ObjectId != _playerGuid())
return;
RefreshMoneyText();
}
private void OnObjectAdded(ClientObject item)
{
TryResolvePendingVendorSplit(item);
if (IsCurrentAlternateCurrency(item)
&& _objects.IsOwnedByObject(item.ObjectId, _playerGuid()))
{
ReconcileAlternateCurrencyInventory();
}
}
private void OnStackSizeUpdated(ClientObject item)
{
if (IsCurrentAlternateCurrency(item)
&& _objects.IsOwnedByObject(item.ObjectId, _playerGuid()))
{
ReconcileAlternateCurrencyInventory();
}
}
private void OnObjectMoved(ClientObjectMove move)
{
if (move.Item is not { } item)
return;
TryResolvePendingVendorSplit(item);
if (!IsCurrentAlternateCurrency(item))
return;
ReconcileAlternateCurrencyInventory();
}
private void ReconcileAlternateCurrencyInventory()
{
_alternateCurrencyInventoryObserved = true;
_lastAlternateCurrencyPurchase = 0;
RefreshMoneyText();
}
private void RefreshMoneyText()
{
UpdateBuyTransactionText();
UpdateSellTransactionText();
// Post-buy gate finding (2026-08-08): the Items tab's cost sentence
@ -1972,6 +2082,57 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
RefreshSelectionDisplay();
}
private bool IsCurrentAlternateCurrency(ClientObject item)
{
uint wcid = _vendor.Profile.AlternateCurrencyWcid;
return wcid != 0u && item.WeenieClassId == wcid;
}
private int ResolveAlternateCurrencyAmount(VendorShopProfile profile)
{
if (profile.AlternateCurrencyWcid == 0u)
return 0;
long live = 0;
bool found = false;
foreach (ClientObject item in _objects.Objects)
{
if (item.WeenieClassId != profile.AlternateCurrencyWcid
|| !_objects.IsOwnedByObject(item.ObjectId, _playerGuid()))
{
continue;
}
found = true;
live += Math.Max(1, item.StackSize);
}
long baseline = found || _alternateCurrencyInventoryObserved
? live
: profile.AlternateCurrencyAmount;
return (int)Math.Clamp(
baseline - _lastAlternateCurrencyPurchase,
0L,
int.MaxValue);
}
private void RecordAlternateCurrencyPurchase(VendorShopProfile profile, int price)
{
if (profile.AlternateCurrencyWcid == 0u || price <= 0)
return;
_lastAlternateCurrencyPurchase = price;
RefreshMoneyText();
}
private void ResetAlternateCurrencyTracking()
{
_lastAlternateCurrencyPurchase = 0;
uint wcid = _vendor.Profile.AlternateCurrencyWcid;
_alternateCurrencyInventoryObserved = wcid != 0u
&& _objects.Objects.Any(item =>
item.WeenieClassId == wcid
&& _objects.IsOwnedByObject(item.ObjectId, _playerGuid()));
}
/// <summary>
/// F1: port of <c>gmVendorUI::InqListSlotCount</c> (<c>pc:200038-200065</c>,
/// <c>0x004c0c10</c>) — see <see cref="BuyAllButtonPressed"/>'s own doc
@ -2216,7 +2377,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
cell.SetItem(shopItem.ItemGuid, icon);
cell.Selected = shopItem.ItemGuid == selectedGuid;
VendorShopItem captured = shopItem;
cell.Clicked = () => _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
cell.Clicked = () =>
_selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
cell.DoubleClicked = () => RemoveOneBuyingUnit(captured.ItemGuid);
list.AddItem(cell);
}
}
@ -2249,13 +2412,16 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
{
SpriteResolve = list.SpriteResolve,
SlotIndex = list.GetNumUIItems(),
AllowDragSource = false,
AllowDragSource = true,
SourceKind = ItemDragSource.Inventory,
TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(),
};
cell.SetItem(item.ObjectId, icon);
cell.Selected = item.ObjectId == selectedGuid;
uint captured = item.ObjectId;
cell.Clicked = () => _selection.Select(captured, SelectionChangeSource.Vendor);
cell.Clicked = () =>
_selection.Select(captured, SelectionChangeSource.Vendor);
cell.DoubleClicked = () => RemoveSellingEntry(captured);
list.AddItem(cell);
}
}
@ -2267,15 +2433,62 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
// gate, pc:204229-204246) ──────────────────────────────────────────────
/// <summary>
/// The Selling list never sources a drag of its own — every staged cell
/// sets <c>AllowDragSource = false</c> (F3, Slice 6 review), the same
/// non-drag-source convention every vendor row uses — so
/// <see cref="UiItemSlot"/>'s drag-lift dispatch (which routes to the
/// SOURCE list's own registered handler) can never actually reach this
/// method in practice. Implemented as a no-op for interface completeness.
/// Retail <c>RecvNotice_ItemListBeginDrag @ 0x004C4380</c>: lifting an
/// already-staged Selling row removes it in full. A partial toolbar split
/// is not applied to this list; retail prints the literal refusal and
/// restores the slider to its maximum.
/// </summary>
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
{
if (!ReferenceEquals(sourceList, _sellingList) || payload.ObjId == 0u)
return;
_selection.Select(payload.ObjId, SelectionChangeSource.Vendor);
RemoveSellingEntry(payload.ObjId, reportRemoval: false);
if (_objects.Get(payload.ObjId) is not { } item)
return;
uint fullStack = (uint)Math.Max(1, item.StackSize);
uint selected = _splitQuantity.GetObjectSplitSize(
payload.ObjId,
_selection.SelectedObjectId ?? 0u,
fullStack);
if (selected < fullStack)
{
_itemInteraction.ReportClientLocal(
"You cannot split items from this panel");
_splitQuantity.Reset(fullStack);
}
}
private void RemoveOneBuyingUnit(uint itemGuid)
{
if (!_buyStaging.TryGet(itemGuid, out _))
return;
_selection.Select(itemGuid, SelectionChangeSource.Vendor);
ReportShoppingListRemoval(itemGuid);
_buyStaging.Remove(itemGuid, 1);
}
private void RemoveSellingEntry(uint itemGuid, bool reportRemoval = true)
{
if (!_sellStaging.TryGet(itemGuid, out _))
return;
_selection.Select(itemGuid, SelectionChangeSource.Vendor);
if (reportRemoval)
ReportShoppingListRemoval(itemGuid);
_sellStaging.Remove(itemGuid, -1);
}
private void ReportShoppingListRemoval(uint itemGuid)
{
string? name = _objects.Get(itemGuid)?.GetAppropriateName();
if (string.IsNullOrWhiteSpace(name))
name = _vendor.Items.FirstOrDefault(item => item.ItemGuid == itemGuid).Name;
if (string.IsNullOrWhiteSpace(name))
name = "that item";
_itemInteraction.ReportClientLocal(
$"Removing {name} from shopping list");
}
public ItemDragAcceptance OnDragOver(
@ -2335,8 +2548,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// <c>silent=0</c>, showing a rejection string) chained into
/// <c>VendorSellUI::AddItemToSell</c> (<c>pc:203546-203567</c>) on
/// success: auto-switch to the "Selling" tab, globally select the
/// dropped item, stage it. Purely client-local — sends nothing to the
/// server, matching the Buying tab's "Add to List".
/// dropped item, and stage it. For a partial stack retail first calls
/// <c>AttemptToPlaceInContainer</c>, stages the source as a temporary
/// row, then replaces that row when the new split object arrives.
/// </summary>
public void HandleDropRelease(
UiItemList targetList,
@ -2356,6 +2570,29 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
ShowTab(VendorPanelTab.Selling);
_selection.Select(payload.ObjId, SelectionChangeSource.Vendor);
ClientObject item = _objects.Get(payload.ObjId)!;
int fullStack = Math.Max(1, item.StackSize);
if (quantity < fullStack)
{
_pendingVendorSplit = new PendingVendorSplit(
payload.ObjId,
item.WeenieClassId,
quantity);
if (!_itemInteraction.TrySplitToContainer(
payload.ObjId,
item.ContainerId,
0u,
(uint)quantity))
{
_pendingVendorSplit = null;
_systemMessage?.Invoke("Cannot split the stack to sell it");
return;
}
string name = string.IsNullOrWhiteSpace(item.Name) ? "item" : item.Name;
_systemMessage?.Invoke($"Splitting the {name} before selling them");
}
_sellStaging.Add(payload.ObjId, quantity);
}
@ -2366,18 +2603,12 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// <paramref name="quantity"/> is the staged quantity a successful drop
/// would use.
/// <para>
/// F6 (Slice 6b/6c review, byte-verified): this is ALWAYS the item's
/// FULL current stack — retail's <c>VendorSellUI::AddItemToSell</c>
/// (<c>pc:203546-203567</c>) stages via <c>gmVendorUI::AddItem(...,
/// itemGuid, -1, ...)</c>, a LITERAL <c>-1</c> "full stack" sentinel
/// argument, never a slider read. A prior version of this port read the
/// LIVE split-quantity slider here instead (the Slice 6b/6c research
/// doc's Q4 section had flagged this exact source as an unverified
/// inferred analogy to the Buying tab's <c>AddToBuyList</c>) — that
/// inference is now known WRONG: Sell staging has no partial-quantity
/// feature in retail at all, unlike Buy. See
/// <c>VendorStagingList.Add</c>'s own doc comment for the Buy side's
/// (genuinely slider-driven) contrast.
/// Retail's full-stack branch does pass the literal <c>-1</c> sentinel
/// to <c>AddItemToSell</c>. The enclosing
/// <c>VendorSellUI::AcceptDragObject</c>, however, first compares the
/// live split slider with the maximum and creates a separate stack when
/// they differ. Therefore the quantity exposed here is the live slider
/// amount for stackables, not always the source's full count.
/// </para>
/// </summary>
private VendorSellRejection EvaluateSellAcceptability(uint itemGuid, out int quantity)
@ -2401,10 +2632,44 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
item.PublicWeenieBitfield ?? 0u);
if (rejection == VendorSellRejection.None)
quantity = (int)Math.Max(1, item.StackSize);
{
uint fullStack = (uint)Math.Max(1, item.StackSize);
quantity = (int)_splitQuantity.GetObjectSplitSize(
itemGuid,
_selection.SelectedObjectId ?? 0u,
fullStack);
}
return rejection;
}
private void TryResolvePendingVendorSplit(ClientObject item)
{
if (_pendingVendorSplit is not { } pending
|| item.ObjectId == pending.SourceGuid
|| item.WeenieClassId != pending.WeenieClassId
|| item.StackSize != pending.Quantity
|| !_objects.IsOwnedByObject(item.ObjectId, _playerGuid()))
{
return;
}
if (_sellStaging.Replace(pending.SourceGuid, item.ObjectId))
_pendingVendorSplit = null;
}
private void OnInventoryRequestFailed(PendingInventoryRequest request, uint _)
{
if (_pendingVendorSplit is not { } pending
|| request.Kind != InventoryRequestKind.SplitToContainer
|| request.ItemId != pending.SourceGuid)
{
return;
}
_sellStaging.Remove(pending.SourceGuid, -1);
_pendingVendorSplit = null;
}
/// <summary>
/// G4/Slice 6b: port of retail's close/pushpin button handler —
/// <c>gmVendorUI::HandleButtonClicks</c>'s <c>0x100000d6</c> case
@ -2564,8 +2829,12 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
_disposed = true;
_vendor.Changed -= OnVendorChanged;
_selection.Changed -= OnSelectionTransition;
_objects.ObjectAdded -= OnObjectAdded;
_objects.ObjectRemoved -= OnObjectRemoved;
_objects.ObjectUpdated -= OnObjectMoneyChanged;
_objects.StackSizeUpdated -= OnStackSizeUpdated;
_objects.ObjectMoved -= OnObjectMoved;
_itemInteraction.RuntimeTransactions.Inventory.RequestFailed -= OnInventoryRequestFailed;
_itemInteraction.StateChanged -= OnInteractionStateChanged;
_splitQuantity.Changed -= OnSplitQuantityChanged;
_buyStaging.Changed -= RebuildBuyingList;
@ -2581,6 +2850,17 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
_typeMenu.OnSelect = null;
_typeMenu.ButtonLabelProvider = null;
_itemList.ExamineItemRequested = null;
_itemList.PrimaryItemPressed = null;
if (_buyingList is not null)
{
_buyingList.ExamineItemRequested = null;
_buyingList.PrimaryItemPressed = null;
}
if (_sellingList is not null)
{
_sellingList.ExamineItemRequested = null;
_sellingList.PrimaryItemPressed = null;
}
if (_close is not null)
_close.OnClick = null;
if (_buyButton is not null)

View file

@ -12,6 +12,7 @@ using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Properties;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
using AcDream.Runtime;
@ -441,7 +442,8 @@ public sealed record VendorRuntimeBindings(
/// Campaign OP slice OP8: the Configure Keyboard screen's live read/write seam —
/// the ONE live <see cref="InputDispatcher"/> (Bindings for reads,
/// SetBindings+BeginCapture for writes/capture) plus the portable
/// <c>keybinds.json</c> path (D4 — no <c>.keymap</c> file interchange). Null
/// <c>keybinds.json</c> mirror path. Retail <c>*.keymap</c> profiles live in
/// Documents/Asheron's Call and the selected profile is reloaded at startup. Null
/// <see cref="Dispatcher"/> (headless/no-window hosts, or before the graphical
/// input stack finishes constructing) degrades to "Configure Keyboard has no
/// live effect" exactly like every other null-dependency Options-panel seam.
@ -464,11 +466,10 @@ public sealed record KeyboardRuntimeBindings(
/// (<c>RecvNotice_CloseDialog@0x004ed760</c> case 1) retail queues UI mode
/// <c>0x10000009</c> (<c>gmEpilogueUI</c>) rather than exiting immediately —
/// out of scope here. This is a plain host action, not a generation-gated
/// Runtime command: it is the SAME window-close path
/// <c>GameplayWindowCommands</c>/<c>IGameplayWindowCommands.Close</c> already
/// use for the in-world Escape fallback (<c>d.Window.Close</c> at
/// composition), so status events <c>disconnected</c>/<c>exited</c> still
/// fire through <c>GameWindow.OnClosing</c> → <c>CompleteShutdown</c>.
/// Runtime command. It closes through <c>d.Window.Close</c>, so status events
/// <c>disconnected</c>/<c>exited</c> still fire through
/// <c>GameWindow.OnClosing</c> → <c>CompleteShutdown</c>. In-world Escape does
/// not use this path; retail clears selection or toggles Gameplay Options.
/// </param>
public sealed record CharacterSelectionRuntimeBindings(
Func<IRuntimeCharacterSelectionView?> View,
@ -529,7 +530,8 @@ public sealed record RetailUiRuntimeBindings(
KeyboardRuntimeBindings? Keyboard = null,
CharacterSelectionRuntimeBindings? CharacterSelection = null,
// Campaign CC slice CC4: sibling of CharacterSelection above.
CharacterCreationRuntimeBindings? CharacterCreation = null);
CharacterCreationRuntimeBindings? CharacterCreation = null,
Action? CaptureScreenshot = null);
/// <summary>
/// Composition owner for the production retained gameplay UI. GameWindow supplies
@ -742,6 +744,7 @@ public sealed class RetailUiRuntime : IDisposable
public VendorUiController? VendorController { get; private set; }
public OptionsPanelController? OptionsPanelController { get; private set; }
public SocialPanelController? SocialPanelController { get; private set; }
private CharacterStatController.Binding? _characterStatBinding;
/// <summary>Campaign QT slice QT5 — the three-tab Journal panel.</summary>
public Layout.JournalPanelController? JournalPanelController { get; private set; }
@ -1006,56 +1009,278 @@ public sealed class RetailUiRuntime : IDisposable
{
if (SpellcastingUiController?.Handle(action) == true)
return true;
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellbookPanel)
switch (action)
{
OpenSpellbook(SpellbookWindowPage.Spells);
return true;
}
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellComponentsPanel)
{
OpenSpellbook(SpellbookWindowPage.Components);
return true;
}
// Campaign FA slice FA3: F3/F4 — keyboard-only open paths (lane A
// §6.1: neither action authors a toolbar button). Both share the
// one social panel (RetailPanelCatalog.SocialPanel) and switch to
// their own tab; the panel participates in the SAME gmPanelUI
// one-active-panel exclusivity every sibling panel gets from
// RetailPanelUiController.RegisterMainPanel.
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleAllegiancePanel)
{
OpenSocialPanel(showAllegiance: true);
return true;
}
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleFellowshipPanel)
{
OpenSocialPanel(showAllegiance: false);
return true;
case AcDream.UI.Abstractions.Input.InputAction.CaptureScreenshot:
_bindings.CaptureScreenshot?.Invoke();
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleHelp:
// EoR delegates this to the separately shipped ACHelpPlugin.
// That binary is not part of acdream; consume the retail action
// and report the unavailable external surface honestly.
_bindings.Options.DisplaySystemMessage(
"In-game help is unavailable because the retail help plugin is not installed.");
return true;
case AcDream.UI.Abstractions.Input.InputAction.TogglePluginManager:
_bindings.Options.DisplaySystemMessage(
"The retail plugin manager is not available in acdream.");
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleAbuseReportingPanel:
_bindings.Options.DisplaySystemMessage(OptionsPanelText.ReportAbuseUnavailable);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleUrgentAssistancePanel:
_bindings.Options.DisplaySystemMessage(OptionsPanelText.UrgentAssistanceUnavailable);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ChatReply:
_chatWindowController?.StartReply(_bindings.Chat.ViewModel.LastIncomingTellSender);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ChatMonarchReply:
_chatWindowController?.StartReply(_bindings.Chat.ViewModel.LastMonarchSender);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ChatPatronReply:
_chatWindowController?.StartReply(_bindings.Chat.ViewModel.LastPatronSender);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ChatStartCommand:
_chatWindowController?.StartCommand();
return true;
case AcDream.UI.Abstractions.Input.InputAction.ChatTellToSelected:
{
uint selected = _bindings.Toolbar.Selection.SelectedObjectId ?? 0u;
if (selected is >= 0x50000001u and <= 0x6FFFFFFFu)
{
string? name = _bindings.Toolbar.ResolveName(selected);
if (!string.IsNullOrEmpty(name))
_chatWindowController?.StartTell(name);
}
return true;
}
case AcDream.UI.Abstractions.Input.InputAction.EnterChatMode:
_chatWindowController?.EnterChatMode(
_bindings.Keyboard?.Dispatcher?.CurrentPhysicalChord);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry:
_chatWindowController?.ToggleChatEntry(
_bindings.Keyboard?.Dispatcher?.CurrentPhysicalChord);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterInfoPanel:
ToggleWindow(WindowNames.CharacterInformation);
return true;
case AcDream.UI.Abstractions.Input.InputAction.TogglePositiveMagicPanel:
ToggleWindow(WindowNames.PositiveEffects);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleNegativeMagicPanel:
ToggleWindow(WindowNames.NegativeEffects);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleLinkStatusPanel:
ToggleWindow(WindowNames.LinkStatus);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleVitaePanel:
ToggleWindow(WindowNames.Vitae);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleSocialPanel:
ToggleWindow(WindowNames.SocialPanel);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleAllegiancePanel:
OpenSocialPanel(SocialPanelPage.Allegiance);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleFellowshipPanel:
OpenSocialPanel(SocialPanelPage.Fellowship);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleFriendsPage:
OpenSocialPanel(SocialPanelPage.Friends);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleSpellManagementPanel:
ToggleWindow(WindowNames.Spellbook);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleSpellbookPanel:
OpenSpellbook(SpellbookWindowPage.Spells);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleSpellComponentsPanel:
OpenSpellbook(SpellbookWindowPage.Components);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterDetailPanel:
ToggleWindow(WindowNames.Character);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleAttributesPanel:
OpenCharacterPanel(CharacterStatController.CharacterStatTab.Attributes);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleSkillsPanel:
OpenCharacterPanel(CharacterStatController.CharacterStatTab.Skills);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterTitlesPage:
OpenCharacterPanel(CharacterStatController.CharacterStatTab.Titles);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleWorldPanel:
ToggleWindow(WindowNames.MapHouse);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleMapPage:
OpenWorldPanel(showHouse: false);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleHousePage:
OpenWorldPanel(showHouse: true);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleOptionsPanel:
ToggleWindow(WindowNames.Options);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleGameplayOptionsPage:
OpenOptionsPage(OptionsPanelPage.Gameplay);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterSettingsPage:
OpenOptionsPage(OptionsPanelPage.Character);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleConfigurationPage:
OpenOptionsPage(OptionsPanelPage.Configuration);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleCompass:
Host.ToggleWindow(WindowNames.Radar);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleKeyboardConfiguration:
ToggleWindow(WindowNames.KeyboardConfig);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleQuestJournalPage:
OpenJournalPanel(JournalPanelPage.Notes);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleQuestDetailPanel:
// EoR's quest-detail action addresses the quest-management
// surface. The current authored Journal host's server-backed
// Contracts page is that surface in acdream.
OpenJournalPanel(JournalPanelPage.Contracts);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleJournalPageList:
OpenJournalPanel(JournalPanelPage.PageList);
return true;
case AcDream.UI.Abstractions.Input.InputAction.ToggleContractsPage:
OpenJournalPanel(JournalPanelPage.Contracts);
return true;
}
return ToolbarInputController?.Handle(action) == true;
}
private void OpenCharacterPanel(CharacterStatController.CharacterStatTab tab)
{
bool visible = Host.IsWindowVisible(WindowNames.Character);
bool onTargetTab = _characterStatBinding?.CurrentTab() == tab;
if (visible && onTargetTab)
{
CloseWindow(WindowNames.Character);
return;
}
_characterStatBinding?.ShowTab(tab);
_panelUi.SetPanelVisibility(RetailPanelCatalog.Character, visible: true);
}
private void OpenWorldPanel(bool showHouse)
{
bool visible = Host.IsWindowVisible(WindowNames.MapHouse);
bool onTargetTab = showHouse
? MapHousePanelController?.IsShowingHouse == true
: MapHousePanelController?.IsShowingMap == true;
if (visible && onTargetTab)
{
CloseWindow(WindowNames.MapHouse);
return;
}
if (showHouse)
MapHousePanelController?.ShowHouse();
else
MapHousePanelController?.ShowMap();
_panelUi.SetPanelVisibility(RetailPanelCatalog.MapHouse, visible: true);
}
private enum OptionsPanelPage { Gameplay, Character, Configuration }
private void OpenOptionsPage(OptionsPanelPage page)
{
bool visible = Host.IsWindowVisible(WindowNames.Options);
bool onTargetTab = page switch
{
OptionsPanelPage.Gameplay => OptionsPanelController?.IsShowingGameplay == true,
OptionsPanelPage.Character => OptionsPanelController?.IsShowingCharacter == true,
OptionsPanelPage.Configuration => OptionsPanelController?.IsShowingConfiguration == true,
_ => false,
};
if (visible && onTargetTab)
{
CloseWindow(WindowNames.Options);
return;
}
switch (page)
{
case OptionsPanelPage.Gameplay: OptionsPanelController?.ShowGameplay(); break;
case OptionsPanelPage.Character: OptionsPanelController?.ShowCharacter(); break;
case OptionsPanelPage.Configuration: OptionsPanelController?.ShowConfiguration(); break;
}
_panelUi.SetPanelVisibility(RetailPanelCatalog.Options, visible: true);
}
/// <summary>
/// Retail Escape's final fallback: toggle action <c>0x1000001B</c>,
/// whose installed-DAT ActionMap label is "Show/Hide Gameplay Options
/// Page". Reuses the authored Options tab and panel owners.
/// </summary>
public void ToggleGameplayOptionsPage()
=> OpenOptionsPage(OptionsPanelPage.Gameplay);
/// <summary>Semantic/rebound form of retail Enter/Tab chat activation.</summary>
public void FocusChatEntry()
{
if (Host.Root.DefaultTextInput is { } input)
Host.Root.SetKeyboardFocus(input);
}
/// <summary>
/// Shift+Escape's retail LOGOUT action: no confirmation dialog; the
/// normal grounded/airborne/no-player gate still applies.
/// </summary>
public void LogOutCharacter() => EndCharacterSessionWithRetailGates();
/// <summary>Shared F3/F4 handler — same "toggle closes on a repeat press
/// of the SAME tab, otherwise show + switch" shape as <see cref="OpenSpellbook"/>.</summary>
private void OpenSocialPanel(bool showAllegiance)
private enum SocialPanelPage { Friends, Allegiance, Fellowship }
private void OpenSocialPanel(SocialPanelPage page)
{
bool visible = Host.IsWindowVisible(WindowNames.SocialPanel);
bool onTargetTab = showAllegiance
? SocialPanelController?.IsShowingAllegiance == true
: SocialPanelController?.IsShowingFellowship == true;
bool onTargetTab = page switch
{
SocialPanelPage.Friends => SocialPanelController?.IsShowingFriends == true,
SocialPanelPage.Allegiance => SocialPanelController?.IsShowingAllegiance == true,
SocialPanelPage.Fellowship => SocialPanelController?.IsShowingFellowship == true,
_ => false,
};
if (visible && onTargetTab)
{
CloseWindow(WindowNames.SocialPanel);
return;
}
if (showAllegiance)
SocialPanelController?.ShowAllegiance();
else
SocialPanelController?.ShowFellowship();
switch (page)
{
case SocialPanelPage.Friends: SocialPanelController?.ShowFriends(); break;
case SocialPanelPage.Allegiance: SocialPanelController?.ShowAllegiance(); break;
case SocialPanelPage.Fellowship: SocialPanelController?.ShowFellowship(); break;
}
_panelUi.SetPanelVisibility(RetailPanelCatalog.SocialPanel, visible: true);
}
private enum JournalPanelPage { Contracts, Notes, PageList }
private void OpenJournalPanel(JournalPanelPage page)
{
switch (page)
{
case JournalPanelPage.Contracts: JournalPanelController?.ShowContracts(); break;
case JournalPanelPage.Notes: JournalPanelController?.ShowNotes(); break;
case JournalPanelPage.PageList: JournalPanelController?.ShowPageList(); break;
}
_panelUi.SetPanelVisibility(RetailPanelCatalog.Journal, visible: true);
}
private void OpenSpellbook(SpellbookWindowPage page)
{
bool visible = Host.IsWindowVisible(WindowNames.Spellbook);
@ -1853,7 +2078,10 @@ public sealed class RetailUiRuntime : IDisposable
StackSplitQuantity,
handler => b.Objects.ObjectUpdated += handler,
handler => b.Objects.ObjectUpdated -= handler,
b.IsVendorSplitExempt);
b.IsVendorSplitExempt,
isCoinstack: guid => b.Objects.Get(guid)?.WeenieClassId == 273u,
coinTotal: () => b.Objects.Get(b.PlayerGuid())?.Properties.GetInt(
(uint)PropertyInt.CoinValue) ?? 0);
UiElement root = layout.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount(
@ -3072,12 +3300,88 @@ public sealed class RetailUiRuntime : IDisposable
string unmappedPath = UnmappedKeyBindingsPath(keyboard.KeyBindingsFilePath);
var unmapped = RetailUnmappedKeyBindings.LoadOrEmpty(unmappedPath);
var keymaps = new RetailKeymapProfileStore(keyboard.KeyBindingsFilePath);
// ID_KeyMapCantOverwriteReadOnlyKeymap_Label — table 0x23000004, byte-
// verified 2026-08-11 (live probe): "Could not overwrite ". Falls back
// to silence (no invented English) if the DAT string is ever missing.
string? refusalText = strings.Resolve(
0x23000004u, DatStringResolver.ComputeHash("ID_KeyMapCantOverwriteReadOnlyKeymap_Label"));
string? ResolveKeymapTemplate(string key, string fileName)
{
// The localized templates use one named filename variable. Keep
// the common retail spellings populated; ResolveTemplate selects
// only the hash actually authored by the DAT entry.
var variables = new Dictionary<uint, string>
{
[DatStringResolver.ComputeHash("LABEL")] = fileName,
[DatStringResolver.ComputeHash("KEYMAP")] = fileName,
[DatStringResolver.ComputeHash("FILENAME")] = fileName,
[DatStringResolver.ComputeHash("NAME")] = fileName,
[DatStringResolver.ComputeHash("VALUE")] = fileName,
};
lock (_bindings.Assets.DatLock)
return strings.ResolveTemplate(0x23000004u, key, variables);
}
void ShowKeymapMessage(string? message)
{
if (!string.IsNullOrWhiteSpace(message) && DialogFactory is not null)
DialogFactory.MakeMessage(message, queueKey: 0x10000001u, priority: true);
}
void SaveMirrors()
{
dispatcher.Bindings.SaveToFile(keyboard.KeyBindingsFilePath);
unmapped.SaveToFile(unmappedPath);
}
void HandleSaveResult(
RetailKeymapSaveResult result,
string requestedName,
Action onSaved)
{
switch (result.Status)
{
case RetailKeymapSaveStatus.Saved:
try
{
SaveMirrors();
}
catch (Exception failure)
{
Console.WriteLine($"keyboard config: JSON mirror save failed: {failure.Message}");
}
// The retail .keymap is the canonical save. A failure in
// acdream's compatibility JSON mirror must not leave the
// authored filename label showing the previous profile.
onSaved();
return;
case RetailKeymapSaveStatus.Exists:
string? overwrite = ResolveKeymapTemplate(
"ID_KeyMapOverwriteKeymap_Label", result.FileName);
if (overwrite is null || DialogFactory is null) return;
DialogFactory.MakeConfirmation(
overwrite,
data =>
{
if (!data.GetBoolean(RetailDialogProperty.ConfirmationResult)) return;
HandleSaveResult(
keymaps.Save(requestedName, dispatcher.Bindings, overwrite: true),
requestedName,
onSaved);
},
queueKey: 0x10000001u,
priority: true);
return;
case RetailKeymapSaveStatus.ReadOnly:
ShowKeymapMessage(ResolveKeymapTemplate(
"ID_KeyMapCantOverwriteReadOnlyKeymap_Label", result.FileName));
return;
default:
Console.WriteLine(
$"keyboard config: keymap save failed ({result.Status}): {result.Error}");
return;
}
}
Layout.KeyboardConfigController? controller = Layout.KeyboardConfigController.Bind(
layout,
@ -3120,15 +3424,12 @@ public sealed class RetailUiRuntime : IDisposable
chord => onResult(chord == default ? null : chord)),
Save: () =>
{
// S3 (2026-08-11 review): match the existing keybinds.json
// writer's own discipline (RuntimeKeyBindingTarget.Apply) —
// an IO failure is reported, not thrown out of UiButton.OnClick
// into the input/render loop, and does not roll back the
// already-accepted live binding.
try
{
dispatcher.Bindings.SaveToFile(keyboard.KeyBindingsFilePath);
unmapped.SaveToFile(unmappedPath);
HandleSaveResult(
keymaps.SaveActive(dispatcher.Bindings),
keymaps.CurrentFileName,
static () => { });
}
catch (Exception failure)
{
@ -3136,11 +3437,23 @@ public sealed class RetailUiRuntime : IDisposable
}
},
Toggle: () => ToggleWindow(WindowNames.KeyboardConfig),
DisplaySystemMessage: text =>
ResolveTemplate: (key, variables) =>
{
if (!string.IsNullOrEmpty(text)) _bindings.Options.DisplaySystemMessage(text);
lock (_bindings.Assets.DatLock)
{
return strings.ResolveTemplate(0x23000004u, key, variables);
}
},
// UIOption_ActionKeyMap::OpenCantOverwriteBindingDialog
// @0x00489300: type 3, keyboard queue 0x10000001, priority.
ShowMessage: message =>
{
if (DialogFactory is null) return;
DialogFactory.MakeMessage(
message,
queueKey: 0x10000001u,
priority: true);
},
NonBindableRefusalText: refusalText ?? string.Empty,
// M3 (2026-08-11 review): retail's OpenOverwriteBindingDialog —
// confirm through the SAME RetailDialogFactory/MakeConfirmation
// seam GameplayConfirmationController already uses, before
@ -3153,7 +3466,9 @@ public sealed class RetailUiRuntime : IDisposable
if (DialogFactory is null) { onResult(false); return; }
DialogFactory.MakeConfirmation(
message,
data => onResult(data.GetBoolean(RetailDialogProperty.ConfirmationResult)));
data => onResult(data.GetBoolean(RetailDialogProperty.ConfirmationResult)),
queueKey: 0x10000001u,
priority: true);
},
// OP8 re-gate (2026-08-14): retail's capture-instruction dialog
// (InitiateBinding @ 0x004899D0 → OpenMapWarnDialog @ 0x00488A00):
@ -3179,7 +3494,10 @@ public sealed class RetailUiRuntime : IDisposable
// `text` arrives with real line breaks.
try
{
return DialogFactory.MakeWait(text, queueKey: 0x10000001u);
return DialogFactory.MakeWait(
text,
queueKey: 0x10000001u,
priority: true);
}
catch (Exception failure)
{
@ -3196,7 +3514,64 @@ public sealed class RetailUiRuntime : IDisposable
}
},
CloseCaptureInstructions: context =>
DialogFactory?.CloseDialog(context)),
DialogFactory?.CloseDialog(context),
CurrentKeymapFilename: () => keymaps.CurrentFileName,
OpenLoadKeymap: onLoaded =>
{
if (DialogFactory is null) return;
IReadOnlyList<string> files = keymaps.ListFiles();
int selected = files
.Select(static (name, index) => (name, index))
.FirstOrDefault(
pair => string.Equals(
pair.name,
keymaps.CurrentFileName,
StringComparison.OrdinalIgnoreCase),
(name: string.Empty, index: 0)).index;
DialogFactory.MakeConfirmationMenu(
files,
selected,
data =>
{
int choice = data.GetInt32(RetailDialogProperty.MenuSelection, -1);
if (choice < 0 || choice >= files.Count) return;
if (!keymaps.TryLoad(
files[choice],
dispatcher.Bindings,
out KeyBindings loaded,
out string? error))
{
Console.WriteLine($"keyboard config: keymap load failed: {error}");
return;
}
dispatcher.SetBindings(loaded);
try { SaveMirrors(); }
catch (Exception failure)
{
Console.WriteLine(
$"keyboard config: loaded profile JSON mirror failed: {failure.Message}");
}
onLoaded();
},
queueKey: 0x10000001u);
},
OpenSaveKeymap: onSaved =>
{
if (DialogFactory is null) return;
DialogFactory.MakeConfirmationTextInput(
string.Empty,
data =>
{
string name = data.GetString(RetailDialogProperty.TextInputResult)
?? string.Empty;
if (name.Length == 0) return;
HandleSaveResult(
keymaps.Save(name, dispatcher.Bindings, overwrite: false),
name,
onSaved);
},
queueKey: 0x10000001u);
}),
resolveTemplateFont: (templateLayoutId, templateElementId) =>
{
lock (_bindings.Assets.DatLock)
@ -4073,7 +4448,7 @@ public sealed class RetailUiRuntime : IDisposable
lock (_bindings.Assets.DatLock)
return RetailDataIdResolver.Resolve(_bindings.Assets.Dats, enumValue, category);
}
Action refreshRows = CharacterStatController.Bind(
_characterStatBinding = CharacterStatController.Bind(
layout,
() => currentSheet,
_bindings.Assets.DefaultFont,
@ -4090,7 +4465,7 @@ public sealed class RetailUiRuntime : IDisposable
_characterSheetSubscription = provider.SubscribeChanged(() =>
{
currentSheet = provider.BuildSheet();
refreshRows();
_characterStatBinding?.Refresh();
});
// CT3 (2026-08-24): the Titles page's row template lives in a

View file

@ -184,6 +184,12 @@ public sealed class UiRoot : UiElement
/// <summary>Widget currently receiving keyboard events.</summary>
public UiElement? KeyboardFocus { get; private set; }
// The dispatcher is attached before retained UI. A semantic binding can
// therefore focus chat before this tree receives the same native key.
// Suppress that exact key through KeyChar/KeyUp so it cannot immediately
// submit the newly-focused field or insert a rebound printable key.
private int? _suppressedPhysicalKey;
/// <summary>The edit control activated by Tab/Enter when nothing is focused — retail's
/// chat input "write mode" toggle. Set by the host once the chat window is built.</summary>
public UiElement? DefaultTextInput { get; set; }
@ -497,7 +503,18 @@ public sealed class UiRoot : UiElement
internal void OnSubtreeRemoving(UiElement subtree)
{
ClearSubtreeOwnership(subtree);
// Inventory/external-container lists rebuild procedurally when an
// authoritative object update arrives. That rebuild removes each old
// UIItem before adding its replacement. Once BeginDrag has promoted
// the gesture, however, retail's UIElementManager owns a separate
// root-level drag element (StartDragandDrop @ 0x0045E040) and transfers
// mouse capture to it; the source list cell is no longer the gesture's
// lifetime owner. Our drag ghost is likewise snapshotted/root-owned,
// so preserve it when the exact source leaf is replaced mid-drag and
// transfer capture to this root. Removing a containing subtree (window
// teardown) still cancels normally.
bool replacingActiveDragSource = ReferenceEquals(subtree, DragSource);
ClearSubtreeOwnership(subtree, preserveDetachedDrag: replacingActiveDragSource);
WindowManager.OnSubtreeRemoving(subtree);
}
@ -511,13 +528,16 @@ public sealed class UiRoot : UiElement
internal void OnElementVisibilityChanged(UiElement element, bool visible)
=> ElementVisibilityChanged?.Invoke(element, visible);
internal void ClearSubtreeOwnership(UiElement subtree)
internal void ClearSubtreeOwnership(UiElement subtree, bool preserveDetachedDrag = false)
{
if (IsWithinSubtree(KeyboardFocus, subtree))
SetKeyboardFocus(null);
if (IsWithinSubtree(Captured, subtree))
{
ReleaseCapture();
if (preserveDetachedDrag && ReferenceEquals(Captured, DragSource))
SetCapture(this);
else
ReleaseCapture();
_dragCandidate = false;
}
if (IsWithinSubtree(DefaultTextInput, subtree))
@ -527,10 +547,13 @@ public sealed class UiRoot : UiElement
if (IsWithinSubtree(DragSource, subtree))
{
DragSource?.SetDragSourceActive(false, DragPayload);
DragSource = null;
DragPayload = null;
_dragGhost = null;
_dragCandidate = false;
if (!preserveDetachedDrag)
{
DragSource = null;
DragPayload = null;
_dragGhost = null;
_dragCandidate = false;
}
}
if (IsWithinSubtree(_hoverWidget, subtree))
{
@ -1090,13 +1113,15 @@ public sealed class UiRoot : UiElement
public void OnKeyDown(int vk, uint lparam = 0)
{
if (_suppressedPhysicalKey == vk)
return;
// Nothing focused yet: Tab or Enter enters "write mode" by focusing the chat
// input (retail's chat-activation hotkeys). Consumed so the same press doesn't
// also fall through to a game hotkey.
if (KeyboardFocus is null && DefaultTextInput is not null
&& (vk == (int)Silk.NET.Input.Key.Tab
|| vk == (int)Silk.NET.Input.Key.Enter
|| vk == (int)Silk.NET.Input.Key.KeypadEnter))
|| vk == (int)Silk.NET.Input.Key.Enter))
{
SetKeyboardFocus(DefaultTextInput);
return;
@ -1125,6 +1150,11 @@ public sealed class UiRoot : UiElement
public void OnKeyUp(int vk, uint lparam = 0)
{
if (_suppressedPhysicalKey == vk)
{
_suppressedPhysicalKey = null;
return;
}
if (KeyboardFocus is not null)
{
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.KeyUp,
@ -1136,12 +1166,18 @@ public sealed class UiRoot : UiElement
public void OnChar(int codepoint)
{
if (_suppressedPhysicalKey is not null)
return;
if (KeyboardFocus is null || !KeyboardFocus.IsEditControl) return;
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.Char,
Data0: codepoint);
BubbleEvent(KeyboardFocus, in e);
}
/// <summary>Suppress the raw retained-UI tail of a semantic key action.</summary>
public void SuppressPhysicalKeyUntilRelease(Silk.NET.Input.Key key)
=> _suppressedPhysicalKey = (int)key;
// ── Focus + capture ─────────────────────────────────────────────────
public void SetKeyboardFocus(UiElement? e)