diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index 55bb81f..71f3239 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -250,6 +250,23 @@ public sealed class PlayerMovementController
///
public bool IsServerAutoWalking => _autoWalkActive;
+ ///
+ /// Fires once when an auto-walk reaches its destination naturally
+ /// (i.e. called with
+ /// reason="arrived"). Does NOT fire on user-input cancel or
+ /// on a re-target (BeginServerAutoWalk overwriting state).
+ ///
+ ///
+ /// Host () subscribes to re-send
+ /// the Use/PickUp action that triggered the auto-walk — without
+ /// this, ACE's server-side MoveToChain may have already timed out
+ /// by the time our local body arrives, so the action wouldn't
+ /// fire. Re-sending the action close-range hits ACE's WithinUseRadius
+ /// fast-path and completes immediately.
+ ///
+ ///
+ public event Action? AutoWalkArrived;
+
public PlayerMovementController(PhysicsEngine physics)
{
_physics = physics;
@@ -384,6 +401,8 @@ public sealed class PlayerMovementController
_autoWalkActive = false;
if (PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[autowalk-end] reason={reason}");
+ if (reason == "arrived")
+ AutoWalkArrived?.Invoke();
}
///
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index b3bc3a9..45ab2c9 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -790,6 +790,15 @@ public sealed class GameWindow : IDisposable
private readonly Dictionary _lastSpawnByGuid = new();
// Current selection: written by Q-cycle (combat) and LMB click (interact); cleared on entity despawn.
private uint? _selectedGuid;
+
+ // B.6+B.7 (2026-05-15): pending action that triggered an auto-walk.
+ // When the local body arrives at the auto-walk target,
+ // OnAutoWalkArrivedReSendAction re-sends the action close-range so
+ // ACE completes it via WithinUseRadius even if its server-side
+ // MoveToChain already timed out. Cleared before each re-send to
+ // prevent infinite loops (the re-sent action's auto-walk would
+ // arrive immediately at the same position, infinite re-fire).
+ private (uint Guid, bool IsPickup)? _pendingPostArrivalAction;
private readonly record struct LiveEntityInfo(
string? Name,
AcDream.Core.Items.ItemType ItemType);
@@ -1177,7 +1186,7 @@ public sealed class GameWindow : IDisposable
&& spawn.ObjectDescriptionFlags is { } odf)
pwdBits = odf;
return new AcDream.App.UI.TargetIndicatorPanel.TargetInfo(
- entity.Position, rawItemType, pwdBits);
+ entity.Position, rawItemType, pwdBits, entity.Scale);
},
cameraProvider: () =>
{
@@ -9096,7 +9105,7 @@ public sealed class GameWindow : IDisposable
else SendUse(sel);
}
- private void SendUse(uint guid)
+ private void SendUse(uint guid, bool isRetryAfterArrival = false)
{
if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
@@ -9114,9 +9123,13 @@ public sealed class GameWindow : IDisposable
Console.WriteLine(System.FormattableString.Invariant(
$"[autowalk-out] op=use target=0x{guid:X8} name=\"{label}\" seq={seq}"));
}
+ // Remember this action so OnAutoWalkArrivedReSendAction can
+ // re-fire it close-range. Skip when this IS the re-send.
+ if (!isRetryAfterArrival)
+ _pendingPostArrivalAction = (guid, false);
}
- private void SendPickUp(uint itemGuid)
+ private void SendPickUp(uint itemGuid, bool isRetryAfterArrival = false)
{
if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
@@ -9149,6 +9162,32 @@ public sealed class GameWindow : IDisposable
Console.WriteLine(System.FormattableString.Invariant(
$"[autowalk-out] op=pickup target=0x{itemGuid:X8} name=\"{label}\" seq={seq}"));
}
+ // Remember this action so OnAutoWalkArrivedReSendAction can
+ // re-fire it close-range. Skip when this IS the re-send.
+ if (!isRetryAfterArrival)
+ _pendingPostArrivalAction = (itemGuid, true);
+ }
+
+ ///
+ /// B.6+B.7 (2026-05-15). Fires when
+ /// signals natural arrival at an auto-walk target. Re-sends the
+ /// Use/PickUp action that started the walk so ACE completes it via
+ /// the WithinUseRadius shortcut even if its server-side MoveToChain
+ /// already gave up.
+ ///
+ private void OnAutoWalkArrivedReSendAction()
+ {
+ if (_pendingPostArrivalAction is not (uint guid, bool isPickup) pending)
+ return;
+ // Clear FIRST to break any retry loop — if ACE somehow re-sends
+ // MoveToObject for the close-range action, we don't want
+ // arrival to fire a third action.
+ _pendingPostArrivalAction = null;
+ if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[autowalk-arrived-resend] guid=0x{guid:X8} isPickup={isPickup}"));
+ if (isPickup) SendPickUp(guid, isRetryAfterArrival: true);
+ else SendUse(guid, isRetryAfterArrival: true);
}
private uint? SelectClosestCombatTarget(bool showToast)
@@ -9320,6 +9359,17 @@ public sealed class GameWindow : IDisposable
}
_playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine);
+
+ // B.6+B.7 (2026-05-15): re-send the Use/PickUp action on local
+ // auto-walk arrival. ACE's server-side MoveToChain may have
+ // already timed out by the time the local body arrives (we
+ // walk locally but don't send tracking position updates to
+ // ACE during the walk yet, so ACE's WithinUseRadius check may
+ // never have passed). Resending close-range hits ACE's
+ // CreateMoveToChain WithinUseRadius shortcut (Player_Move.cs:66)
+ // and completes the action immediately.
+ _playerController.AutoWalkArrived += OnAutoWalkArrivedReSendAction;
+
// K-fix7 (2026-04-26): if PlayerDescription already arrived, the
// server's Run / Jump skill values are cached here — push them
// into the freshly-constructed controller so the runRate /
diff --git a/src/AcDream.App/UI/TargetIndicatorPanel.cs b/src/AcDream.App/UI/TargetIndicatorPanel.cs
index 21d86ae..e657f40 100644
--- a/src/AcDream.App/UI/TargetIndicatorPanel.cs
+++ b/src/AcDream.App/UI/TargetIndicatorPanel.cs
@@ -38,11 +38,15 @@ public sealed class TargetIndicatorPanel
/// What the panel needs to know about the selected entity per frame.
/// ItemType + ObjectDescriptionFlags feed
/// for colour selection.
+ /// Scale multiplies the per-type base height in
+ /// — a scaled-up sign or oversized NPC
+ /// gets a proportionally bigger box.
///
public readonly record struct TargetInfo(
Vector3 WorldPosition,
uint ItemType,
- uint ObjectDescriptionFlags);
+ uint ObjectDescriptionFlags,
+ float Scale);
private readonly Func _selectedGuidProvider;
private readonly Func _entityResolver;
@@ -64,24 +68,68 @@ public sealed class TargetIndicatorPanel
///
/// Resolve the world-space height to use for a given entity's
- /// indicator box. Humanoids (Creature flag) use 1.8 m; doors /
- /// lifestones / portals use 2.4 m (door-frame tall); ground items
- /// use 0.5 m so the box hugs the item rather than ballooning out
- /// to humanoid height. Falls back to
- /// for entities without a recognisable type tag.
+ /// indicator box. The base height per type is multiplied by the
+ /// entity's so an upscaled sign or NPC
+ /// gets a proportionally bigger box.
+ ///
+ /// Per-type base height:
+ ///
+ /// - Creature (NPC / monster / player): 1.8 m (humanoid)
+ /// - Door / Lifestone / Portal: 2.4 m (door-frame tall)
+ /// - Small carry items (Money, Food, Gem, SpellComponents,
+ /// Misc, Weapons, Armour, Clothing, Jewelry, Container):
+ /// 0.8 m (item dropped on the ground)
+ /// - Everything else (signs, generic objects, untyped
+ /// scenery interactables): 1.5 m (mid-sized object
+ /// default; without mesh AABB this is a best guess)
+ ///
+ ///
+ ///
+ /// Future refinement (deferred): read the entity's actual mesh
+ /// AABB at registration time and use the projected silhouette
+ /// for an exact-fit box. Issue #66-ish.
+ ///
///
- public float EntityHeightFor(uint itemType, uint pwdBitfield)
+ public float EntityHeightFor(uint itemType, uint pwdBitfield, float scale)
{
+ if (scale <= 0f) scale = 1f; // defensive
bool isCreature = (itemType & (uint)AcDream.Core.Items.ItemType.Creature) != 0;
- if (isCreature) return 1.8f;
+ if (isCreature) return 1.8f * scale;
// BF_DOOR = 0x1000, BF_LIFESTONE = 0x4000, BF_PORTAL = 0x40000
// (acclient.h:6431-6463)
const uint TallStructureMask = 0x1000u | 0x4000u | 0x40000u;
- if ((pwdBitfield & TallStructureMask) != 0) return 2.4f;
+ if ((pwdBitfield & TallStructureMask) != 0) return 2.4f * scale;
- // Default: small ground item / object.
- return 0.5f;
+ // Small carry items: weapons / armour / clothing / jewellery /
+ // money / food / misc / weapons / containers / gems / spell
+ // components / writable / keys / casters / lockables / promissory
+ // notes / mana stones / craft bases / craft intermediates /
+ // tinkering tools / tinkering materials / gameboard.
+ // Per AcDream.Core.Items.ItemType — most non-zero values in
+ // the lower 28 bits map to carryable items.
+ const uint SmallItemMask =
+ (uint)(AcDream.Core.Items.ItemType.MeleeWeapon
+ | AcDream.Core.Items.ItemType.Armor
+ | AcDream.Core.Items.ItemType.Clothing
+ | AcDream.Core.Items.ItemType.Jewelry
+ | AcDream.Core.Items.ItemType.Food
+ | AcDream.Core.Items.ItemType.Money
+ | AcDream.Core.Items.ItemType.Misc
+ | AcDream.Core.Items.ItemType.MissileWeapon
+ | AcDream.Core.Items.ItemType.Container
+ | AcDream.Core.Items.ItemType.Gem
+ | AcDream.Core.Items.ItemType.SpellComponents
+ | AcDream.Core.Items.ItemType.Writable
+ | AcDream.Core.Items.ItemType.Key
+ | AcDream.Core.Items.ItemType.Caster);
+ if ((itemType & SmallItemMask) != 0) return 0.8f * scale;
+
+ // Everything else (signs, scenery interactables, untyped objects):
+ // 1.5 m default — bigger than a small item but smaller than a
+ // humanoid, splitting the difference until we have real mesh
+ // bounds to project.
+ return 1.5f * scale;
}
///
@@ -132,7 +180,7 @@ public sealed class TargetIndicatorPanel
// projection.
if (!TryProjectToScreen(info.WorldPosition, viewProj, viewport, out var feetScreen))
return;
- float entityHeight = EntityHeightFor(info.ItemType, info.ObjectDescriptionFlags);
+ float entityHeight = EntityHeightFor(info.ItemType, info.ObjectDescriptionFlags, info.Scale);
var headWorld = new Vector3(
info.WorldPosition.X,
info.WorldPosition.Y,