fix(ui): retail target-cursor shape (pending over UI, world-object driven) + visible UseDone refusals
Visual gate round 2 (user, retail oracle): the target cursor over UI must be the 0x27 four-arrows PENDING cursor — retail's UpdateCursorState (0x00564630) keys valid/invalid solely off the SmartBox found object, i.e. the WORLD entity under the cursor. And "can't heal myself" turned out to be TWO stacked causes, both fixed: - Cursor: UI hover no longer forces TargetInvalid (that arm was a non-retail invention) and the doll/status-bar hover providers are gone (UiElement.UseTargetGuidProvider deleted). Valid/invalid now come from (a) a hovered occupied item slot's own item, or (b) the world entity under the cursor via a new worldTargetProvider — the B.4b screen-rect picker extracted into GameWindow.PickWorldGuidAtCursor and shared by click + hover. - Self-heal: the world picker always skipped the local player (skipServerGuid), so clicking your own toon in target mode could never acquire self. Target-use picks now include self (retail lets you kit-heal yourself by clicking your character); plain selection keeps the exclusion. The doll click still self-targets. - Silent refusals: the [use-target] log proved the UseWithTarget action WAS sent (kit 0x00220008 / TargetType 0x10, target self) — ACE refused with WeenieError.YouArentTrainedInHealing (0x04FC) and we never parsed UseDone (0x01C7). Now dispatched + surfaced as a chat line via WeenieErrorText (interim subset map, register AP-74, #166 ports the retail String-table lookup). Full suite green (3,295). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
047410ccc9
commit
769ebef30d
10 changed files with 151 additions and 61 deletions
|
|
@ -1844,7 +1844,12 @@ public sealed class GameWindow : IDisposable
|
|||
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
|
||||
sendDrop: item => _liveSession?.SendDropItem(item),
|
||||
toast: text => _debugVm?.AddToast(text));
|
||||
_cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(_itemInteractionController);
|
||||
_cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
|
||||
_itemInteractionController,
|
||||
// Retail UpdateCursorState (0x00564630) keys target-mode
|
||||
// valid/invalid off the SmartBox found object — the world
|
||||
// entity under the cursor, self included.
|
||||
worldTargetProvider: () => PickWorldGuidAtCursor(includeSelf: true) ?? 0u);
|
||||
_retailCursorManager = new RetailCursorManager(_dats!, _datLock);
|
||||
_characterSheetProvider = new AcDream.App.UI.Layout.CharacterSheetProvider(
|
||||
Objects, LocalPlayer,
|
||||
|
|
@ -12375,26 +12380,27 @@ public sealed class GameWindow : IDisposable
|
|||
// The inbound reply (SetState 0xF74B) lands via L.2g slice 1.
|
||||
// ============================================================
|
||||
|
||||
private void PickAndStoreSelection(bool useImmediately)
|
||||
/// <summary>
|
||||
/// Shared world pick at the current cursor — the 2026-05-16
|
||||
/// retail-faithful screen-rect picker (hit area = the target
|
||||
/// indicator's rect via the shared ScreenProjection helper; the old
|
||||
/// per-type radius/offset heuristics are retired). Used by the click
|
||||
/// select path AND the target-mode cursor hover (retail
|
||||
/// SmartBox::get_found_object_id analogue). <paramref name="includeSelf"/>:
|
||||
/// item target-use may pick the LOCAL PLAYER (retail lets you kit-heal
|
||||
/// yourself by clicking your own toon); plain selection never does.
|
||||
/// </summary>
|
||||
private uint? PickWorldGuidAtCursor(bool includeSelf)
|
||||
{
|
||||
if (_cameraController is null || _window is null) return;
|
||||
if (_cameraController is null || _window is null) return null;
|
||||
|
||||
// 2026-05-16 — retail-faithful screen-rect picker. The hit area
|
||||
// is the same screen-space rect the target indicator draws
|
||||
// (computed via the shared AcDream.Core.Selection.ScreenProjection
|
||||
// helper). Per user feedback 2026-05-16: clicking the indicator
|
||||
// brackets — including the rect corners — must select the entity.
|
||||
// The per-type radius/offset heuristics retired here (1.0/1.6/2.0
|
||||
// m radii, 0.2/0.9/1.0/1.5 m vertical offsets, IsTallSceneryGuid)
|
||||
// existed to make a 3D ray-sphere picker approximate the visible
|
||||
// rect; the new picker doesn't need them.
|
||||
var camera = _cameraController.Active;
|
||||
var viewport = new System.Numerics.Vector2((float)_window.Size.X, (float)_window.Size.Y);
|
||||
|
||||
// Indoor walking Phase 1 #86 (2026-05-19): snapshot the currently-
|
||||
// cached EnvCell physics so the picker can occlude entities behind
|
||||
// walls. Snapshot is per-pick (one click), iteration is bounded
|
||||
// by the streaming radius (~80 cells at radius 4).
|
||||
// walls. Snapshot is per-pick (one click / one hover frame),
|
||||
// iteration is bounded by the streaming radius (~80 cells at radius 4).
|
||||
var loadedCellPhysics = new List<AcDream.Core.Physics.CellPhysics>();
|
||||
foreach (var cellId in _physicsDataCache.CellStructIds)
|
||||
{
|
||||
|
|
@ -12402,12 +12408,12 @@ public sealed class GameWindow : IDisposable
|
|||
if (cp is not null) loadedCellPhysics.Add(cp);
|
||||
}
|
||||
|
||||
var picked = AcDream.Core.Selection.WorldPicker.Pick(
|
||||
return AcDream.Core.Selection.WorldPicker.Pick(
|
||||
mouseX: _lastMouseX, mouseY: _lastMouseY,
|
||||
view: camera.View, projection: camera.Projection,
|
||||
viewport: viewport,
|
||||
candidates: _entitiesByServerGuid.Values,
|
||||
skipServerGuid: _playerServerGuid,
|
||||
skipServerGuid: includeSelf ? 0u : _playerServerGuid,
|
||||
// Resolver: Setup's SelectionSphere is the ONLY input. If the
|
||||
// entity's Setup didn't bake a SelectionSphere, return null —
|
||||
// the picker skips it, which matches retail behaviour
|
||||
|
|
@ -12429,6 +12435,16 @@ public sealed class GameWindow : IDisposable
|
|||
? (origin, direction) =>
|
||||
AcDream.Core.Selection.CellBspRayOccluder.NearestWallT(origin, direction, loadedCellPhysics)
|
||||
: null);
|
||||
}
|
||||
|
||||
private void PickAndStoreSelection(bool useImmediately)
|
||||
{
|
||||
if (_cameraController is null || _window is null) return;
|
||||
|
||||
// Target-use mode picks WITH self (retail: kit-heal yourself by
|
||||
// clicking your own toon); plain selection keeps the self-exclusion.
|
||||
var picked = PickWorldGuidAtCursor(
|
||||
includeSelf: _itemInteractionController?.IsTargetModeActive == true);
|
||||
|
||||
if (picked is uint guid)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -42,10 +42,14 @@ public readonly record struct CursorFeedbackSnapshot(
|
|||
public sealed class CursorFeedbackController
|
||||
{
|
||||
private readonly ItemInteractionController? _itemInteraction;
|
||||
private readonly Func<uint>? _worldTargetProvider;
|
||||
|
||||
public CursorFeedbackController(ItemInteractionController? itemInteraction = null)
|
||||
public CursorFeedbackController(
|
||||
ItemInteractionController? itemInteraction = null,
|
||||
Func<uint>? worldTargetProvider = null)
|
||||
{
|
||||
_itemInteraction = itemInteraction;
|
||||
_worldTargetProvider = worldTargetProvider;
|
||||
}
|
||||
|
||||
public CursorFeedback Current { get; private set; } = CursorFeedback.Default;
|
||||
|
|
@ -55,6 +59,20 @@ public sealed class CursorFeedbackController
|
|||
ArgumentNullException.ThrowIfNull(root);
|
||||
|
||||
UiElement? hover = root.Pick(root.MouseX, root.MouseY);
|
||||
|
||||
// Retail UpdateCursorState (0x00564630) keys the target-mode cursor off
|
||||
// the SmartBox found object — the WORLD entity under the cursor. A UI
|
||||
// window occludes the world (no found object → pending). The one
|
||||
// UI-side source retail-style cells contribute is an occupied item
|
||||
// slot's own item.
|
||||
uint hoverTarget = 0;
|
||||
if (_itemInteraction?.IsTargetModeActive == true)
|
||||
{
|
||||
hoverTarget = hover is not null
|
||||
? FindHoveredItemSlot(hover)?.ItemId ?? 0u
|
||||
: _worldTargetProvider?.Invoke() ?? 0u;
|
||||
}
|
||||
|
||||
var snapshot = new CursorFeedbackSnapshot(
|
||||
DragPayload: root.DragPayload,
|
||||
DragAccept: FindHoveredItemSlot(hover)?.DragAcceptVisual ?? UiItemSlot.DragAcceptState.None,
|
||||
|
|
@ -64,7 +82,7 @@ public sealed class CursorFeedbackController
|
|||
HoverWindowMove: root.HoverWindowMove,
|
||||
HoverUi: hover is not null,
|
||||
HoverTextEdit: hover?.IsEditControl == true,
|
||||
HoverTargetGuid: ResolveUseTargetGuid(hover));
|
||||
HoverTargetGuid: hoverTarget);
|
||||
|
||||
var kind = ResolveKind(snapshot);
|
||||
Current = new CursorFeedback(kind, ResolveCursor(root.Captured, hover, kind));
|
||||
|
|
@ -110,9 +128,12 @@ public sealed class CursorFeedbackController
|
|||
: CursorFeedbackKind.TargetInvalid;
|
||||
}
|
||||
|
||||
return snapshot.HoverUi
|
||||
? CursorFeedbackKind.TargetInvalid
|
||||
: CursorFeedbackKind.TargetPending;
|
||||
// Retail UpdateCursorState (0x00564630), TARGET_MODE 3: no found
|
||||
// object → the 0x27 four-arrows pending cursor — INCLUDING over
|
||||
// UI chrome. Valid/invalid exist only with a target under the
|
||||
// cursor. (The earlier HoverUi → Invalid arm was a non-retail
|
||||
// invention — 2026-07-03 visual gate.)
|
||||
return CursorFeedbackKind.TargetPending;
|
||||
}
|
||||
|
||||
if (snapshot.HoverWindowMove)
|
||||
|
|
@ -236,20 +257,4 @@ public sealed class CursorFeedbackController
|
|||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static uint ResolveUseTargetGuid(UiElement? element)
|
||||
{
|
||||
while (element is not null)
|
||||
{
|
||||
uint provided = element.UseTargetGuidProvider?.Invoke() ?? 0u;
|
||||
if (provided != 0)
|
||||
return provided;
|
||||
|
||||
if (element is UiItemSlot { ItemId: not 0 } slot)
|
||||
return slot.ItemId;
|
||||
|
||||
element = element.Parent;
|
||||
}
|
||||
return 0u;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,7 +99,6 @@ public sealed class PaperdollController : IItemListDragHandler
|
|||
list.Cell.SourceKind = ItemDragSource.Equipment;
|
||||
list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield
|
||||
list.Cell.EmptySprite = emptySlotSprite; // visible empty-slot frame so every position is seen + usable. The live
|
||||
list.Cell.UseTargetGuidProvider = _playerGuid;
|
||||
list.Cell.Clicked = () => { _itemInteraction?.AcquireSelfTarget(); };
|
||||
list.Cell.DoubleClicked = () =>
|
||||
{
|
||||
|
|
@ -125,15 +124,12 @@ public sealed class PaperdollController : IItemListDragHandler
|
|||
if (layout.FindElement(id) is UiItemList armor) _armorSlots.Add(armor);
|
||||
|
||||
_dollViewport = layout.FindElement(0x100001D5u); // doll viewport (may be null until Slice 3)
|
||||
if (_dollViewport is not null)
|
||||
{
|
||||
// The doll IS the player: hovering it during target-use resolves the
|
||||
// valid/invalid cursor for SELF (retail: the SmartBox target is your
|
||||
// own creature), and clicking it self-targets like the equip cells.
|
||||
_dollViewport.UseTargetGuidProvider = _playerGuid;
|
||||
if (_dollViewport is UiViewport doll)
|
||||
doll.Clicked = () => { _itemInteraction?.AcquireSelfTarget(); };
|
||||
}
|
||||
// The doll IS the player: clicking it during target-use self-targets
|
||||
// like the equip cells do. (The CURSOR over it stays the pending
|
||||
// four-arrows — retail resolves target cursors off the SmartBox world
|
||||
// object only; UI hover is always pending. 2026-07-03 visual gate.)
|
||||
if (_dollViewport is UiViewport doll)
|
||||
doll.Clicked = () => { _itemInteraction?.AcquireSelfTarget(); };
|
||||
|
||||
var slotsBtnEl = layout.FindElement(0x100005BEu);
|
||||
if (slotsBtnEl is UiButton slotsBtn)
|
||||
|
|
|
|||
|
|
@ -230,8 +230,6 @@ public sealed class ToolbarController : IItemListDragHandler
|
|||
{
|
||||
if (_inventoryButton is not null)
|
||||
{
|
||||
if (_itemInteraction is not null)
|
||||
_inventoryButton.UseTargetGuidProvider = () => _itemInteraction.PlayerGuid;
|
||||
_inventoryButton.OnClick = () =>
|
||||
{
|
||||
if (_itemInteraction?.AcquireSelfTarget() == true) return;
|
||||
|
|
|
|||
|
|
@ -183,14 +183,6 @@ public abstract class UiElement
|
|||
/// self-driven interior drag such as text selection). Default false.</summary>
|
||||
public virtual bool HandlesClick => false;
|
||||
|
||||
/// <summary>
|
||||
/// Optional game-object target used by item-use cursor/target mode when this UI
|
||||
/// element represents a target that is not its visible item icon. Example:
|
||||
/// the status-bar backpack button and paperdoll both mean "self" for a
|
||||
/// health-kit-style use-with-target action.
|
||||
/// </summary>
|
||||
public Func<uint>? UseTargetGuidProvider { get; set; }
|
||||
|
||||
/// <summary>Minimum size enforced while resizing.</summary>
|
||||
public float MinWidth { get; set; } = 40f;
|
||||
public float MinHeight { get; set; } = 40f;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue