feat(retail): Commit B — retail-faithful AP cadence + screen-rect picker

Retires divergences flagged in the 2026-05-16 faithfulness audit:

1. AP cadence. Replaces the 1 Hz idle / 10 Hz active flat heartbeat
   with a diff-driven model gated on `Contact && OnWalkable`
   (acclient_2013_pseudo_c.txt:700327 SendPositionEvent). Sends on
   position or cell change while grounded on walkable, plus a 1 sec
   heartbeat; suppressed entirely airborne. PlayerMovementController
   exposes `NotePositionSent(pos, cellId, now)` which GameWindow stamps
   after each AutonomousPosition / MoveToState send — mirrors retail's
   shared `last_sent_position_time` between SendPositionEvent
   (0x006b4770) and SendMovementEvent (0x006b4680). Known divergence
   from retail: ours is per-frame-while-moving, retail's effective rate
   is ~1 Hz during smooth motion (cell/plane checks). Filed as #74,
   blocked by #63 — when #63 lands we revert to retail's narrower gate.

2. Workaround retirement. Removes TinyMargin (0.05 m inside arrival)
   and the AP-flush before re-send (`SendAutonomousPositionNow`). The
   diff-driven cadence makes both obsolete. Close-range turn-first
   deferred Use is kept (it IS retail — ACE Player_Move.cs:66-87
   mirrors retail's CreateMoveToChain pre-callback rotation), renamed
   `OnAutoWalkArrivedSendDeferredAction` to clarify it's a FIRST send.
   `isRetryAfterArrival` parameter dropped.

3. Far-range Use/PickUp retry. Restored — was load-bearing, not the
   "redundant cleanup" the Group 2 audit thought. Issue #63 means ACE
   drops the first Use as too-far without re-polling on subsequent APs;
   the arrival re-send is what makes far-range Use complete. Logs
   include `(queued for arrival re-send pending #63)` to make this
   explicit. Removes when #63 closes.

4. Screen-rect picker. New `AcDream.Core.Selection.ScreenProjection`
   helper shared by `WorldPicker` and `TargetIndicatorPanel`. The
   `Setup.SelectionSphere` projects to a screen-space square (retail
   anchor `SmartBox::GetObjectBoundingBox` 0x00452e20); picker
   hit-tests the mouse pixel against the same rect the indicator draws,
   inflated by 8 px (`TriangleSize`). Guarantees what-you-see is
   what-you-click — including rect corners that were dead zones under
   the old ray-sphere picker. Per-type radius (1.0/1.6/2.0 m) and
   vertical-offset (0.2/0.9/1.0/1.5 m) heuristic lambdas retired;
   `IsTallSceneryGuid` deleted; `EntityHeightFor` trimmed to 1.5 m × scale
   defensive default. No defensive sphere synth — entities without a
   baked `SelectionSphere` are skipped, matching retail's
   `GfxObjUnderSelectionRay` (0x0054c740).

5. Rotation rate run multiplier (Commit A precursor). `TurnRateFor(running)`
   helper applies retail's `run_turn_factor = 1.5f` (PDB-named
   0x007c8914) under HoldKey.Run, matching `apply_run_to_command` at
   0x00527be0 (line 305098). Effective: walking ≈ 90°/s, running ≈ 135°/s.
   Keyboard A/D + ApplyAutoWalkOverlay both use it.

6. Useability gate (Commit A precursor). `IsUseableTarget` corrected to
   `useability != 0` per `ItemUses::IsUseable` at 256455 — ANY non-zero
   passes (USEABLE_NO=1, USEABLE_CONTAINED=8, etc.), not just the
   USEABLE_REMOTE bit. Cross-checked against 4 call sites in retail
   (ItemHolder::UseObject 0x00588a80, DetermineUseResult 0x402697,
   UsingItem 0x367638, disable-button-state 0x198826). Added
   `ProbeUseabilityFallbackEnabled` diagnostic
   (`ACDREAM_PROBE_USEABILITY_FALLBACK=1`) to measure how often the
   creature/BF_DOOR fallback fires for ACE-seed-DB entities with
   null useability.

CLAUDE.md updated with the graceful-shutdown rule for relaunch:
Stop-Process bypasses the logout packet, leaving ACE's session marked
logged-in for ~3+ min. CloseMainWindow() sends WM_CLOSE so the
shutdown hook runs and the logout packet reaches ACE.

Tests: +3 ScreenProjectionTests + 6 WorldPickerRectOverloadTests = +9.
Core.Net 294/294 pass; Core 1073/1081 (8 pre-existing Physics failures
unchanged). Visual-verified 2026-05-16: rotation rate, useability,
screen-rect click area, double-click + R-key + F-key Use/PickUp at
short and long range — dialogue/door/pickup fire on arrival.

Filed follow-ups #70 (triangle apex/size DAT sprite), #71 (picker
Stage B polygon refine), #72 (cdb omega.z probe), #73 (retail-message
sweep pattern), #74 (per-frame AP chattier than retail — blocked by
#63). Old ray-sphere `WorldPicker.Pick(origin, direction, ...)`
overload kept for back-compat; no callers in acdream proper.

Plan: docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-16 13:56:08 +02:00
parent e2bc3a9e99
commit b5da17db76
10 changed files with 1348 additions and 573 deletions

View file

@ -186,10 +186,32 @@ public sealed class PlayerMovementController
// (2026-05-01 motion-trace findings.md): retail sends ~1 Hz at rest,
// not the 5 Hz our pre-fix code used. Sending at 5 Hz was harmless
// but wasteful and probably looked like jitter to observers.
private float _heartbeatAccum;
public const float HeartbeatInterval = 1.0f; // 1 sec — retail / holtburger
/// <summary>
/// 2026-05-16 — retail-faithful AP cadence. Matches retail's
/// CommandInterpreter::ShouldSendPositionEvent (acclient_2013_pseudo_c.txt
/// at address 0x006b45e0) which gates on either (a) position-or-cell
/// change since the last send, or (b) at-rest 1 sec heartbeat elapsed.
/// `time_between_position_events` constant at 0x006b3efb = 1.0 sec.
///
/// Old model: a 1 Hz idle / 10 Hz active flat accumulator. That
/// missed retail's per-frame-while-moving behaviour and forced the
/// four B.6 workarounds (arrival margin, re-send on arrival, AP
/// flush, retry flag) to compensate for the lag in ACE's server-side
/// WithinUseRadius poll. Replaced by diff-driven cadence below.
/// </summary>
public const float HeartbeatInterval = 1.0f; // retail 0x006b3efb
private System.Numerics.Vector3 _lastSentPos;
private uint _lastSentCellId;
private float _lastSentTime;
private bool _lastSentInitialized;
private float _simTimeSeconds;
public bool HeartbeatDue { get; private set; }
/// <summary>Sim-time accumulator (advanced by dt at the top of Update).
/// Exposed for the network outbound layer to stamp NotePositionSent.</summary>
public float SimTimeSeconds => _simTimeSeconds;
// L.5 retail physics-tick gate (2026-04-30).
//
// Retail's CPhysicsObj::update_object subdivides per-frame dt into
@ -405,6 +427,27 @@ public sealed class PlayerMovementController
AutoWalkArrived?.Invoke();
}
/// <summary>
/// 2026-05-16. Called by the network outbound layer after every
/// AutonomousPosition or MoveToState that carries the player's
/// position. Resets the diff-driven heartbeat clock so the next
/// `HeartbeatDue` evaluation requires either a fresh position
/// change OR another full HeartbeatInterval. Mirrors retail's
/// SendPositionEvent (0x006b4770) which updates
/// `last_sent_position_time` + `last_sent_position` at every
/// send, AND SendMovementEvent (0x006b4680) which also touches
/// the same shared clock (both consumers of the 1 sec window).
/// </summary>
public void NotePositionSent(System.Numerics.Vector3 worldPos,
uint cellId,
float nowSeconds)
{
_lastSentPos = worldPos;
_lastSentCellId = cellId;
_lastSentTime = nowSeconds;
_lastSentInitialized = true;
}
/// <summary>
/// B.6 slice 2 (2026-05-14). If a server-initiated auto-walk is
/// active, either cancel it (user pressed a movement key) or
@ -463,11 +506,17 @@ public sealed class PlayerMovementController
float arrivalThreshold = _autoWalkMoveTowards
? _autoWalkDistanceToObject
: _autoWalkMinDistance;
const float TinyMargin = 0.05f;
float effectiveArrival = MathF.Max(arrivalThreshold - TinyMargin, 0.1f);
// 2026-05-16 — retail "stop at the radius" semantics.
// Previously had a 0.05 m TinyMargin inside the threshold to
// ensure ACE's server-side WithinUseRadius poll saw us inside
// the radius before our next AP heartbeat. With the
// diff-driven AP cadence (Task B2) ACE sees the final position
// the same frame we arrive — no margin needed. Retail's
// arrival check is `dist <= radius` exact at
// CMotionInterp::apply_interpreted_movement integration.
bool withinArrival =
(_autoWalkMoveTowards
&& dist <= effectiveArrival)
&& dist <= arrivalThreshold)
|| (!_autoWalkMoveTowards
&& dist >= arrivalThreshold + RemoteMoveToDriver.ArrivalEpsilon);
@ -613,6 +662,8 @@ public sealed class PlayerMovementController
public MovementResult Update(float dt, MovementInput input)
{
_simTimeSeconds += dt;
// B.6 slice 2 (2026-05-14): server-initiated auto-walk overlay.
// When _autoWalkActive, steer Yaw toward _autoWalkDestination and
// synthesize Forward+Run input so the rest of Update runs the
@ -1192,31 +1243,46 @@ public sealed class PlayerMovementController
}
// ── 8. Heartbeat timer (always while in-world, not just while moving) ─
// Holtburger fires AutonomousPosition heartbeat at 1 Hz regardless of
// motion state (gated only by has_autonomous_position_sync_target).
// Retail's CommandInterpreter::SendPositionEvent gates on
// transient_state (Contact + OnWalkable + valid Position), not on
// motion. The pre-fix isMoving gate stopped acdream from heart-beating
// at rest, which left observers with stale last-known positions during
// long idle periods. PortalSpace (handled at the top of Update via
// early return) skips Update entirely, so reaching this line implies
// we're in a valid in-world pose.
_heartbeatAccum += dt;
// B.6+B.7 (2026-05-15): bump heartbeat from 1 Hz to ~10 Hz while
// the body is actively moving (auto-walk OR user pressing W/A/S/D).
// ACE's server-side CreateMoveToChain polls WithinUseRadius every
// ~0.1 s using the latest Player.Location; 1 Hz heartbeats leave
// up to 1 s of stale position data on the server, which meant
// ACE's MoveToChain rejected our re-sent Use action as still
// out-of-range. With 10 Hz updates ACE sees us approaching in
// ~real-time and the server-side chain converges normally —
// retires the arrival-margin / re-send / flush-AP workarounds.
bool activelyMoving = _autoWalkActive
|| input.Forward || input.Backward
|| input.StrafeLeft || input.StrafeRight;
float effectiveInterval = activelyMoving ? 0.1f : HeartbeatInterval;
HeartbeatDue = _heartbeatAccum >= effectiveInterval;
if (HeartbeatDue) _heartbeatAccum = 0f;
// 2026-05-16 — retail diff-driven AP cadence (acclient_2013_pseudo_c.txt
// 0x006b45e0 ShouldSendPositionEvent + 0x006b4770 SendPositionEvent).
//
// Rules:
// - When interval elapsed (>= 1 sec since last send): send.
// - When interval NOT elapsed: send only if position or cell
// differs from last_sent (Frame::is_equal check at
// acclient_2013_pseudo_c.txt:700248-700265).
// - SendPositionEvent (acclient_2013_pseudo_c.txt:700327)
// gates on `((state & 1) != 0 && (state & 2) != 0)` —
// Contact (CONTACT_TS bit 0) AND OnWalkable (ON_WALKABLE_TS
// bit 1) BOTH set. Two independent `& != 0` tests joined
// by `&&`, NOT a single bitwise-OR mask test. Airborne
// (neither bit) and wall-contact-without-walkable (Contact
// only) both suppress AP. MoveToState carries jump/fall
// snapshots while airborne.
//
// Effective rate: per-frame while moving on the ground, 1 Hz at-rest
// heartbeat, 0 Hz airborne. Retires the 1 Hz / 10 Hz flat model.
//
// If NotePositionSent has never been called (no network session),
// _lastSentInitialized stays false and we treat every frame as
// "first send" — HeartbeatDue fires once per frame, which matches
// "send if anything to send" semantics.
bool intervalElapsed = !_lastSentInitialized
|| (_simTimeSeconds - _lastSentTime) >= HeartbeatInterval;
bool positionChanged =
!_lastSentInitialized
|| _lastSentCellId != CellId
|| !ApproxPositionEqual(_lastSentPos, _body.Position);
// Grounded-on-walkable. Retail's CONTACT_TS + ON_WALKABLE_TS
// (acclient.h:3688). Our equivalent: PhysicsBody.InContact &&
// PhysicsBody.OnWalkable (both map to TransientStateFlags bits 0+1
// which are set together by ResolveWithTransition on walkable ground).
bool groundedOnWalkable = _body.InContact && _body.OnWalkable;
HeartbeatDue = groundedOnWalkable && (positionChanged || intervalElapsed);
// K-fix5 (2026-04-26): local-animation-cycle pacing. Visual rate
// should match the actual movement speed. For Forward+Run this is
@ -1256,4 +1322,21 @@ public sealed class PlayerMovementController
JumpExtent: outJumpExtent,
JumpVelocity: outJumpVelocity);
}
/// <summary>
/// 2026-05-16. Position-equality test for diff-driven AP cadence.
/// Retail uses Frame::is_equal at acclient_2013_pseudo_c.txt:700263
/// which is essentially exact float comparison after a memcmp of
/// the frame struct. For floating-point safety we use a tiny epsilon
/// — sub-millimeter — that's well below any movement we'd want to
/// suppress sending for.
/// </summary>
private static bool ApproxPositionEqual(
System.Numerics.Vector3 a, System.Numerics.Vector3 b)
{
const float Epsilon = 0.001f; // 1 mm
return MathF.Abs(a.X - b.X) < Epsilon
&& MathF.Abs(a.Y - b.Y) < Epsilon
&& MathF.Abs(a.Z - b.Z) < Epsilon;
}
}

View file

@ -791,13 +791,11 @@ public sealed class GameWindow : IDisposable
// 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).
// B.6/B.7 (2026-05-16): pending close-range action that will be fired
// once the local auto-walk overlay reports arrival (body has finished
// rotating to face the target). Only set for close-range Use/PickUp;
// far-range sends fire the wire packet immediately at SendUse/SendPickUp
// time. Cleared before the deferred send fires — single-fire, no retry.
private (uint Guid, bool IsPickup)? _pendingPostArrivalAction;
private readonly record struct LiveEntityInfo(
string? Name,
@ -6445,6 +6443,14 @@ public sealed class GameWindow : IDisposable
DumpMovementTruthOutbound(
"MTS", seq, result, wirePos, wireCellId, contactByte);
_liveSession.SendGameAction(body);
// B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so
// HeartbeatDue resets its interval from THIS send — mirrors retail's
// SendMovementEvent (acclient_2013_pseudo_c.txt:0x006b4680) writing
// last_sent_position_time + last_sent_position after each MTS send.
_playerController.NotePositionSent(
worldPos: _playerController.Position,
cellId: _playerController.CellId,
nowSeconds: _playerController.SimTimeSeconds);
}
if (_playerController.HeartbeatDue)
@ -6463,6 +6469,14 @@ public sealed class GameWindow : IDisposable
DumpMovementTruthOutbound(
"AP", seq, result, wirePos, wireCellId, contactByte);
_liveSession.SendGameAction(body);
// B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so
// HeartbeatDue resets its interval from THIS send — mirrors retail's
// SendPositionEvent (acclient_2013_pseudo_c.txt:700345-700348)
// writing last_sent_position_time + last_sent_position after each AP.
_playerController.NotePositionSent(
worldPos: _playerController.Position,
cellId: _playerController.CellId,
nowSeconds: _playerController.SimTimeSeconds);
}
if (result.JumpExtent.HasValue && result.JumpVelocity.HasValue)
@ -9007,69 +9021,41 @@ public sealed class GameWindow : IDisposable
{
if (_cameraController is null || _window is null) return;
// 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 (origin, direction) = AcDream.Core.Selection.WorldPicker.BuildRay(
mouseX: _lastMouseX, mouseY: _lastMouseY,
viewportW: _window.Size.X, viewportH: _window.Size.Y,
view: camera.View, projection: camera.Projection);
if (direction.LengthSquared() < 1e-6f) return; // degenerate ray
var viewport = new System.Numerics.Vector2((float)_window.Size.X, (float)_window.Size.Y);
var picked = AcDream.Core.Selection.WorldPicker.Pick(
origin, direction,
_entitiesByServerGuid.Values,
mouseX: _lastMouseX, mouseY: _lastMouseY,
view: camera.View, projection: camera.Projection,
viewport: viewport,
candidates: _entitiesByServerGuid.Values,
skipServerGuid: _playerServerGuid,
maxDistance: 50f,
// B.7 (2026-05-15): widen the pick sphere for large flat
// objects (doors, lifestones, portals, corpses) so their
// visible surface stays clickable even though the entity
// origin is a single point. 0.7 m default is fine for
// humanoids and most items; doors / portals need ~2 m
// to cover the doorframe.
//
// 2026-05-15 sign-class extension: post-mounted scenery
// (Holtburg town sign etc.) needs the sphere TALLER than
// wider. We classify "non-creature, non-flat, non-small-item"
// as tall scenery and grow the sphere to 1.6 m radius lifted
// to 1.5 m vertical offset — covers a 3 m post from
// ground to top. Mirrors TargetIndicatorPanel.EntityHeightFor's
// 3 m default so the click sphere matches the visible box.
radiusForGuid: g =>
{
if (_lastSpawnByGuid.TryGetValue(g, out var s)
&& s.ObjectDescriptionFlags is { } odf)
{
// BF_DOOR = 0x1000, BF_LIFESTONE = 0x4000,
// BF_PORTAL = 0x40000, BF_CORPSE = 0x2000
// (acclient.h:6431-6463)
const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
if ((odf & LargeFlatMask) != 0) return 2.0f;
}
if (IsTallSceneryGuid(g)) return 1.6f;
// 1.0 m sphere centred at chest height (see
// verticalOffsetForGuid) covers a 1.8 m humanoid from
// shin to crown without overlapping neighbours.
return 1.0f;
},
verticalOffsetForGuid: g =>
{
// Lift the pick sphere to mid-body so chest/head clicks
// hit instead of missing past the top of a feet-anchored
// sphere. WorldEntity.Position is at feet level
// (Z=ground); humanoids reach ~1.8 m, items sit close
// to the ground (~0.2 m above their feet).
if (_liveEntityInfoByGuid.TryGetValue(g, out var info)
&& (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0)
return 0.9f; // humanoid mid-body
if (_lastSpawnByGuid.TryGetValue(g, out var s)
&& s.ObjectDescriptionFlags is { } odf)
{
const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
if ((odf & LargeFlatMask) != 0) return 1.0f; // mid-door
}
if (IsTallSceneryGuid(g)) return 1.5f; // mid-pole height
return 0.2f; // small ground item — sphere just above feet
});
// 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
// (Render::GfxObjUnderSelectionRay at 0x0054c740 skips
// candidates with no drawing_sphere data). Earlier defensive
// 1.5 m × scale synth was removed 2026-05-16 — it made
// dat-incomplete entities click as phantom hitboxes the size
// of an NPC, diverging from retail and masking real Setup-
// loading bugs.
sphereForEntity: e =>
TryGetEntitySelectionSphere(e.ServerGuid, out var c, out var r)
? ((System.Numerics.Vector3, float)?)(c, r)
: null,
// Match the indicator's TriangleSize (8 px) so the click area
// extends out to the bracket corners — what the user perceives
// as "selectable extent."
inflatePixels: 8f);
if (picked is uint guid)
{
@ -9102,7 +9088,7 @@ public sealed class GameWindow : IDisposable
string radStr = pickUseRadius.HasValue ? pickUseRadius.Value.ToString("F2", System.Globalization.CultureInfo.InvariantCulture) : "null";
string setupStr = pickSetupId.HasValue ? $"0x{pickSetupId.Value:X8}" : "null";
Console.WriteLine(System.FormattableString.Invariant(
$"[B.7] pick-info guid=0x{guid:X8} itemType=0x{rawItemType:X8} pwd=0x{pwdBits:X8} use={useStr} useRadius={radStr} scale={pickScale:F2} setup={setupStr} tallScenery={IsTallSceneryGuid(guid)} color=({col.R},{col.G},{col.B})"));
$"[B.7] pick-info guid=0x{guid:X8} itemType=0x{rawItemType:X8} pwd=0x{pwdBits:X8} use={useStr} useRadius={radStr} scale={pickScale:F2} setup={setupStr} color=({col.R},{col.G},{col.B})"));
_debugVm?.AddToast($"Selected: {label}");
if (useImmediately) SendUse(guid);
}
@ -9161,7 +9147,7 @@ public sealed class GameWindow : IDisposable
else SendUse(sel);
}
private void SendUse(uint guid, bool isRetryAfterArrival = false)
private void SendUse(uint guid)
{
if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
@ -9169,69 +9155,64 @@ public sealed class GameWindow : IDisposable
_debugVm?.AddToast("Not in world");
return;
}
// 2026-05-15: defense-in-depth useability gate. Double-click flows
// directly through SendUse without passing UseCurrentSelection's
// dispatcher gate, so re-check here. Silent ignore matches retail
// (acclient.h:6478 ITEM_USEABLE — USEABLE_REMOTE bit required).
// isRetryAfterArrival bypasses the gate because we only retry an
// action we previously gated through.
if (!isRetryAfterArrival && !IsUseableTarget(guid))
// Retail-faithful useability gate (acclient_2013_pseudo_c.txt:256455
// ItemUses::IsUseable). Signs / banners with useability=0 silently
// ignore Use.
if (!IsUseableTarget(guid))
{
// Retail-style client-side toast for unusable targets
// (signs, decorative scenery with USEABLE_NO / USEABLE_UNDEF).
// Retail string at acclient_2013_pseudo_c.txt:1033115
// (data_7e2a70): "The %s cannot be used" (no trailing period).
string label = DescribeLiveEntity(guid);
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotBeUsed(label));
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.4b] SendUse ignored — not useable guid=0x{guid:X8}");
return;
}
// B.6 (2026-05-15): install a speculative auto-walk on the local
// player toward the target. For far targets ACE will overwrite
// this with its own MovementType=6 wire payload (and a better
// wire-supplied use-radius). For close-range targets ACE skips
// MoveToChain entirely and just rotates server-side; our
// overlay provides the matching local rotation. Either way the
// alignment-gated arrival ensures the body finishes facing
// the target before stopping.
// B.6 (2026-05-15): install speculative local auto-walk against
// the target so close-range Use rotates the body to face before
// the action fires. For FAR targets, ACE's CreateMoveToChain
// (Player_Move.cs:37-179) takes over via inbound MovementType=6
// and our overlay is overwritten by ACE's wire-supplied radius.
//
// User feedback (2026-05-15): 'first is rotation, when you are
// facing, then using.' For close-range we DEFER the wire packet
// until our local overlay arrives (turn-then-fire). The
// _pendingPostArrivalAction handler will re-fire SendUse with
// isRetryAfterArrival=true after the body finishes turning.
// For far range we still send immediately so ACE can start
// its MoveToChain.
if (!isRetryAfterArrival)
// 2026-05-16: simplified — close-range deferral now fires the
// wire packet ONCE on AutoWalkArrived (turn-first done), not a
// retry of an earlier failed send. No re-send path.
bool closeRange = IsCloseRangeTarget(guid);
InstallSpeculativeTurnToTarget(guid);
if (closeRange)
{
InstallSpeculativeTurnToTarget(guid);
// Defer the wire packet — OnAutoWalkArrivedSendDeferredAction
// will fire it after rotation completes.
_pendingPostArrivalAction = (guid, false);
if (IsCloseRangeTarget(guid))
{
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}");
return; // wait for AutoWalkArrived to fire the wire send
}
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}");
return;
}
// Far range: send Use immediately so ACE has the request,
// AND queue an arrival re-send. Issue #63 (server-initiated
// MoveToObject not honored) means ACE's first-Use response
// is dropped as too-far and ACE doesn't re-poll
// WithinUseRadius when the speculative local walk gets us in
// range. The arrival re-send fires a second Use packet once
// the body reaches the target — at which point ACE accepts
// and executes the action. The retail-faithful path is to
// honor MoveToObject and let ACE complete the Use server-
// side; until #63 lands, this client-side retry is the
// workaround that keeps far-range Use working.
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
_liveSession.SendGameAction(body);
Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq}");
_pendingPostArrivalAction = (guid, false);
Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq} (queued for arrival re-send pending #63)");
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
{
string label = DescribeLiveEntity(guid);
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, bool isRetryAfterArrival = false)
private void SendPickUp(uint itemGuid)
{
if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
@ -9245,7 +9226,7 @@ public sealed class GameWindow : IDisposable
// "cannot pick up creatures!" message instead of the generic
// "can't be picked up!".
// Retail string acclient_2013_pseudo_c.txt:401642 (data_7e22b4).
if (!isRetryAfterArrival && IsLiveCreatureTarget(itemGuid))
if (IsLiveCreatureTarget(itemGuid))
{
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotPickUpCreatures);
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
@ -9256,7 +9237,7 @@ public sealed class GameWindow : IDisposable
// Generic non-pickupable gate (signs, banners, decorative scenery).
// Retail string acclient_2013_pseudo_c.txt:401589 (sprintf
// "The %s can't be picked up!").
if (!isRetryAfterArrival && !IsPickupableTarget(itemGuid))
if (!IsPickupableTarget(itemGuid))
{
string label = DescribeLiveEntity(itemGuid);
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CantBePickedUp(label));
@ -9264,66 +9245,75 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"[B.5] SendPickUp ignored — not pickupable item=0x{itemGuid:X8}");
return;
}
// B.6 (2026-05-15): same speculative turn-to-target + deferral as
// SendUse — close-range pickup rotates locally to face the
// item first, then the wire packet fires when the local
// overlay reports arrival.
if (!isRetryAfterArrival)
//
// 2026-05-16: simplified — FIRST send on arrival, not a retry.
bool closeRange = IsCloseRangeTarget(itemGuid);
InstallSpeculativeTurnToTarget(itemGuid);
if (closeRange)
{
InstallSpeculativeTurnToTarget(itemGuid);
_pendingPostArrivalAction = (itemGuid, true);
if (IsCloseRangeTarget(itemGuid))
{
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.5] pickup deferred (close-range, turn-first) item=0x{itemGuid:X8}");
return;
}
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.5] pickup deferred (close-range, turn-first) item=0x{itemGuid:X8}");
return;
}
// Far range: same arrival-retry pattern as SendUse — fire
// PickUp immediately AND queue for arrival re-send. ACE's
// first PickUp is dropped if we're outside the use-radius
// and isn't re-polled (issue #63 workaround).
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
seq, itemGuid, _playerServerGuid, placement: 0);
_liveSession.SendGameAction(body);
Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq}");
_pendingPostArrivalAction = (itemGuid, true);
Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq} (queued for arrival re-send pending #63)");
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
{
string label = DescribeLiveEntity(itemGuid);
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);
}
/// <summary>
/// B.6+B.7 (2026-05-15). Fires when <see cref="PlayerMovementController.AutoWalkArrived"/>
/// signals natural arrival at an auto-walk target. Force-flushes
/// the player's current authoritative position to ACE first, then
/// re-sends the Use/PickUp. Without the position flush, ACE
/// processes the re-sent Use before the next per-frame
/// AutonomousPosition heartbeat — so ACE's Player.Location is
/// still stale (back where the auto-walk started) and ACE replies
/// with another MoveToObject instead of completing the action.
/// 2026-05-16. Fires the deferred close-range Use/PickUp action
/// once the local auto-walk overlay reports arrival (i.e. the body
/// has finished rotating to face the target). Unlike the old
/// <c>OnAutoWalkArrivedReSendAction</c>, this is a FIRST send — not a
/// retry of an earlier failed send. Far-range Use/PickUp paths
/// fire the wire packet immediately at <see cref="SendUse"/>/<see cref="SendPickUp"/> time
/// and never touch <c>_pendingPostArrivalAction</c>.
/// </summary>
private void OnAutoWalkArrivedReSendAction()
private void OnAutoWalkArrivedSendDeferredAction()
{
if (_pendingPostArrivalAction is not (uint guid, bool isPickup) pending)
return;
// Clear FIRST to break any retry loop.
_pendingPostArrivalAction = null;
// Send a fresh AutonomousPosition NOW so ACE's server-side
// Player.Location updates to our arrived position before ACE
// sees the re-sent action.
SendAutonomousPositionNow();
if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
return;
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);
var seq = _liveSession.NextGameActionSequence();
if (isPickup)
{
var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
seq, guid, _playerServerGuid, placement: 0);
_liveSession.SendGameAction(body);
Console.WriteLine($"[B.5] pickup-deferred item=0x{guid:X8} container=0x{_playerServerGuid:X8} seq={seq}");
}
else
{
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
_liveSession.SendGameAction(body);
Console.WriteLine($"[B.4b] use-deferred guid=0x{guid:X8} seq={seq}");
}
}
/// <summary>
@ -9421,48 +9411,6 @@ public sealed class GameWindow : IDisposable
walkRunThreshold: 15.0f);
}
/// <summary>
/// B.6+B.7 (2026-05-15). Send an out-of-frame AutonomousPosition
/// packet using the controller's current authoritative state.
/// Used to flush position to ACE on auto-walk arrival before
/// re-sending the Use/PickUp action; without it, ACE's
/// Player.Location is the pre-walk position and the action
/// resolves out-of-range.
/// </summary>
private void SendAutonomousPositionNow()
{
if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld
|| _playerController is null)
return;
var pos = _playerController.Position;
int lbX = _liveCenterX + (int)MathF.Floor(pos.X / 192f);
int lbY = _liveCenterY + (int)MathF.Floor(pos.Y / 192f);
float localX = pos.X - (lbX - _liveCenterX) * 192f;
float localY = pos.Y - (lbY - _liveCenterY) * 192f;
uint wireCellId = ((uint)lbX << 24) | ((uint)lbY << 16) | (_playerController.CellId & 0xFFFFu);
var wirePos = new System.Numerics.Vector3(localX, localY, pos.Z);
var wireRot = YawToAcQuaternion(_playerController.Yaw);
byte contactByte = _playerController.IsAirborne ? (byte)0 : (byte)1;
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.AutonomousPosition.Build(
gameActionSequence: seq,
cellId: wireCellId,
position: wirePos,
rotation: wireRot,
instanceSequence: _liveSession.InstanceSequence,
serverControlSequence: _liveSession.ServerControlSequence,
teleportSequence: _liveSession.TeleportSequence,
forcePositionSequence: _liveSession.ForcePositionSequence,
lastContact: contactByte);
_liveSession.SendGameAction(body);
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[autowalk-flush-ap] seq={seq} cell=0x{wireCellId:X8} pos=({wirePos.X:F2},{wirePos.Y:F2},{wirePos.Z:F2})"));
}
private uint? SelectClosestCombatTarget(bool showToast)
{
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
@ -9515,74 +9463,6 @@ public sealed class GameWindow : IDisposable
return (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0;
}
/// <summary>
/// 2026-05-15. True when the entity is "tall scenery" — has a known
/// non-zero ItemType that is NOT in the small-carry-item mask AND
/// has no door/lifestone/portal/corpse PWD bits AND is not a
/// creature. The Holtburg town sign is the canonical example: a
/// 3 m post-mounted entity that needs the pick sphere lifted to
/// mid-pole with a wider radius so the user can click any part of
/// the visible mesh, not just the pole base.
///
/// <para>
/// Mirrors <see cref="UI.TargetIndicatorPanel.EntityHeightFor"/>'s
/// classification — both fall into the "everything else: 3 m default"
/// branch — so the visible indicator box and the click sphere
/// match.
/// </para>
/// </summary>
private bool IsTallSceneryGuid(uint guid)
{
// Creatures are never "tall scenery" — picker uses humanoid sphere.
if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)
&& (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0)
return false;
if (!_lastSpawnByGuid.TryGetValue(guid, out var spawn))
return false;
// Doors / lifestones / portals / corpses → LargeFlatMask branch.
if (spawn.ObjectDescriptionFlags is { } odf)
{
const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
if ((odf & LargeFlatMask) != 0) return false;
}
uint it = spawn.ItemType ?? 0u;
// 2026-05-15 — useability-based discriminator. Mirrors
// TargetIndicatorPanel.EntityHeightFor exactly so the click
// sphere matches the visible box. A small-item ItemType is
// a REAL pickup item only if it is also useable from the
// world (USEABLE_REMOTE bit, acclient.h:6478). Otherwise
// (Misc + USEABLE_UNDEF — the Holtburg sign case) it's tall
// scenery and gets the lifted+widened sphere.
const uint USEABLE_REMOTE_BIT = 0x20u;
bool useableFromWorld = spawn.Useability is uint u
&& (u & USEABLE_REMOTE_BIT) != 0;
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);
// Real pickup item: small-item-class AND useable. NOT tall scenery.
if ((it & SmallItemMask) != 0 && useableFromWorld) return false;
// Everything else (signs / banners / untyped scenery /
// Misc-typed-but-non-useable): tall scenery.
return true;
}
/// <summary>
/// 2026-05-16 — retail-faithful port of
@ -9955,15 +9835,10 @@ 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;
// B.6/B.7 (2026-05-16): fire the deferred close-range Use/PickUp
// action (first send, not a retry) when the local auto-walk overlay
// reports arrival (body finished rotating to face the target).
_playerController.AutoWalkArrived += OnAutoWalkArrivedSendDeferredAction;
// K-fix7 (2026-04-26): if PlayerDescription already arrived, the
// server's Run / Jump skill values are cached here — push them

View file

@ -84,89 +84,18 @@ public sealed class TargetIndicatorPanel
public float EntityHeight { get; set; } = 1.8f;
/// <summary>
/// Resolve the world-space height to use for a given entity's
/// indicator box. The base height per type is multiplied by the
/// entity's <paramref name="scale"/> so an upscaled sign or NPC
/// gets a proportionally bigger box.
///
/// <para>Per-type base height:</para>
/// <list type="bullet">
/// <item>Creature (NPC / monster / player): 1.8 m (humanoid)</item>
/// <item>Door / Lifestone / Portal: 2.4 m (door-frame tall)</item>
/// <item>Small carry items (Money, Food, Gem, SpellComponents,
/// Misc, Weapons, Armour, Clothing, Jewelry, Container):
/// 0.8 m (item dropped on the ground)</item>
/// <item>Everything else (signs on a pole, generic tall scenery,
/// untyped scenery interactables): 3.0 m (post-on-ground
/// tall — bumped from 1.5 m on 2026-05-15 because the
/// Holtburg sign was getting a tiny pole-only box. Most
/// non-typed non-flat AC scenery is either small-item-on-
/// ground (handled above) or post-mounted; 3 m is the
/// right midpoint for the post case. Scale &gt; 1 grows
/// the box proportionally.)</item>
/// </list>
///
/// <para>
/// Future refinement (deferred): read the entity's actual mesh
/// AABB at registration time and use the projected silhouette
/// for an exact-fit box.
/// <see cref="AcDream.Core.Physics.PhysicsDataCache.GetVisualBounds"/>
/// already caches per-GfxObj AABBs; combining them across a
/// multi-part Setup gives the entity-level bounds we'd want.
/// </para>
/// Defensive fallback height when the entity has no usable
/// SelectionSphere (Radius ≤ 1e-4f). With B.7's sphere-projection
/// path active (since commit f4f4143), this fallback only fires
/// for entities whose Setup didn't bake a selection sphere —
/// rare in practice. The single 1.5 m × scale default is a sane
/// midpoint; per-type branches were retired in the 2026-05-16
/// Commit B because the sphere path is authoritative.
/// </summary>
public float EntityHeightFor(uint itemType, uint pwdBitfield, float scale, uint? useability = null)
{
if (scale <= 0f) scale = 1f; // defensive
bool isCreature = (itemType & (uint)AcDream.Core.Items.ItemType.Creature) != 0;
if (isCreature) return 1.8f * scale;
// BF_DOOR = 0x1000, BF_LIFESTONE = 0x4000, BF_PORTAL = 0x40000,
// BF_CORPSE = 0x2000 (acclient.h:6431-6463).
const uint TallStructureMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
if ((pwdBitfield & TallStructureMask) != 0) return 2.4f * scale;
// 2026-05-15 — KEY DISCRIMINATOR. Misc-class ItemTypes are
// ambiguous in retail: dropped jewellery / coins / food / tapers
// are Misc, but so are signs, banners, and decorative scenery.
// ACE distinguishes the two via ITEM_USEABLE (acclient.h:6478):
// a real pickup item has USEABLE_REMOTE (0x20) set; a sign has
// USEABLE_UNDEF (0). If we know useability and it lacks
// USEABLE_REMOTE, treat the entity as tall scenery regardless
// of ItemType. This is what fixes the Holtburg town sign
// showing a tiny pole-base box.
const uint USEABLE_REMOTE_BIT = 0x20u;
bool useableFromWorld = useability is uint u
&& (u & USEABLE_REMOTE_BIT) != 0;
// Small carry items: weapons / armour / clothing / jewellery /
// money / food / misc / weapons / containers / gems / spell
// components / writable / keys / casters / lockables.
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);
// Real pickup item: ItemType is small-item-class AND the server
// marked it useable from the world. 0.8 m × scale box.
if ((itemType & SmallItemMask) != 0 && useableFromWorld) return 0.8f * scale;
// Tall scenery: anything else (signs, banners, untyped
// post-mounted objects, AND Misc-typed-but-non-useable entities
// like the Holtburg sign). 3 m × scale covers a typical
// post-mounted sign from ground to top.
return 3.0f * scale;
if (scale <= 0f) scale = 1f;
return 1.5f * scale;
}
/// <summary>
@ -214,8 +143,10 @@ public sealed class TargetIndicatorPanel
if (info.WorldSphereCenter is Vector3 sphereCenter
&& info.WorldSphereRadius is float sphereRadius
&& TryComputeScreenRectFromSphere(sphereCenter, sphereRadius, view, projection, viewport,
out var rMin, out var rMax))
&& AcDream.Core.Selection.ScreenProjection.TryProjectSphereToScreenRect(
sphereCenter, sphereRadius, view, projection, viewport,
out var rMin, out var rMax, out _,
minSidePixels: 12f))
{
// 2026-05-16 — retail-faithful path per
// SmartBox::GetObjectBoundingBox (decomp 0x00452e20).
@ -294,67 +225,6 @@ public sealed class TargetIndicatorPanel
drawList.AddTriangleFilled(bl + new Vector2( t, -t), bl + new Vector2( t, 0), bl + new Vector2(0, -t), col);
}
/// <summary>
/// 2026-05-16. Project a world-space sphere (center + radius) as a
/// screen-space square and return its bounding rectangle. Matches
/// retail <c>SmartBox::GetObjectBoundingBox</c> (decomp
/// <c>0x00452e20</c>) which uses
/// <c>Render::GetViewerBBox(sphere, &amp;corner1, &amp;corner2)</c>
/// to compute a camera-aligned BBox of the sphere then projects
/// the 2 corner points.
///
/// <para>
/// Mathematical equivalent (faster, no per-corner reprojection):
/// project the sphere center to screen, then compute the
/// screen-space radius as
/// <c>worldRadius * projection.M22 * viewport.Y / (2 * clip.W)</c>
/// where <c>M22 = 1/tan(fovY/2)</c> for a standard right-handed
/// perspective. The rect is centered at the projected sphere
/// center with side length <c>2 * screenRadius</c>.
/// </para>
///
/// <para>
/// Returns <c>false</c> if the sphere center is behind the camera
/// (<c>clip.W &lt;= 0</c>).
/// </para>
/// </summary>
private static bool TryComputeScreenRectFromSphere(
Vector3 worldCenter, float worldRadius,
Matrix4x4 view, Matrix4x4 projection, Vector2 viewport,
out Vector2 rectMin, out Vector2 rectMax)
{
rectMin = default;
rectMax = default;
var viewProj = view * projection;
var clip = Vector4.Transform(new Vector4(worldCenter, 1f), viewProj);
if (clip.W <= 0.001f) return false;
float ndcX = clip.X / clip.W;
float ndcY = clip.Y / clip.W;
float screenX = (ndcX * 0.5f + 0.5f) * viewport.X;
float screenY = (1f - (ndcY * 0.5f + 0.5f)) * viewport.Y;
// Screen-space radius. projection.M22 = cot(fovY/2). clip.W is
// the camera-space distance (positive in front of camera for
// standard right-handed perspective).
float scaleY = projection.M22;
if (scaleY <= 0f) return false;
float screenRadius = worldRadius * scaleY * viewport.Y / (2f * clip.W);
// Cull obviously-off-screen entities (more than a screen away).
if (screenX + screenRadius < -viewport.X || screenX - screenRadius > 2f * viewport.X) return false;
if (screenY + screenRadius < -viewport.Y || screenY - screenRadius > 2f * viewport.Y) return false;
// Floor at MinSide so distant entities still get a visible indicator.
const float MinSide = 12f;
if (screenRadius < MinSide * 0.5f) screenRadius = MinSide * 0.5f;
rectMin = new Vector2(screenX - screenRadius, screenY - screenRadius);
rectMax = new Vector2(screenX + screenRadius, screenY + screenRadius);
return true;
}
/// <summary>
/// Project a world-space point to screen-space pixels. Returns
/// <c>false</c> if the point is behind the camera or outside the

View file

@ -0,0 +1,86 @@
using System.Numerics;
namespace AcDream.Core.Selection;
/// <summary>
/// Shared screen-space projection math for the target indicator and the
/// world picker. Both call into <see cref="TryProjectSphereToScreenRect"/>
/// so the click hit-area is guaranteed to match the visible indicator
/// rect — "what you see is what you click".
///
/// <para>
/// Retail equivalent: <c>SmartBox::GetObjectBoundingBox</c> at
/// <c>0x00452e20</c>, which uses
/// <c>Render::GetViewerBBox(selection_sphere, &amp;corner1, &amp;corner2)</c>
/// to compute a camera-aligned bbox of the sphere and projects the two
/// corner points. We use the mathematical equivalent (project center,
/// compute screen radius analytically) — both produce identical pixel
/// rects for a standard right-handed perspective.
/// </para>
/// </summary>
public static class ScreenProjection
{
/// <summary>
/// Project a world-space sphere to a screen-space axis-aligned square
/// rectangle.
/// </summary>
/// <param name="worldCenter">Sphere center in world space.</param>
/// <param name="worldRadius">Sphere radius in world space.</param>
/// <param name="view">View matrix (System.Numerics row-vector convention).</param>
/// <param name="projection">Projection matrix. <c>M22 = cot(fovY/2)</c>
/// for a standard right-handed perspective.</param>
/// <param name="viewport">Viewport size in pixels (X = width, Y = height).</param>
/// <param name="rectMin">Out: top-left corner of the rect in viewport pixels.</param>
/// <param name="rectMax">Out: bottom-right corner of the rect in viewport pixels.</param>
/// <param name="depth">Out: camera-space depth (<c>clip.W</c>) of the sphere
/// center — use this for nearest-first sorting when multiple rects overlap.</param>
/// <param name="minSidePixels">Minimum side length of the rect. Distant
/// entities clamp to this so they remain pickable / visible. 12 px
/// matches the indicator's clamp floor.</param>
/// <returns>
/// <c>true</c> if the sphere is in front of the camera and the rect was
/// produced; <c>false</c> if the center is behind the camera
/// (<c>clip.W &lt;= 0</c>) or the rect is more than a screen offset
/// from the viewport (obviously off-screen).
/// </returns>
public static bool TryProjectSphereToScreenRect(
Vector3 worldCenter, float worldRadius,
Matrix4x4 view, Matrix4x4 projection, Vector2 viewport,
out Vector2 rectMin, out Vector2 rectMax, out float depth,
float minSidePixels = 12f)
{
rectMin = default;
rectMax = default;
depth = 0f;
var viewProj = view * projection;
var clip = Vector4.Transform(new Vector4(worldCenter, 1f), viewProj);
if (clip.W <= 0.001f) return false;
depth = clip.W;
float ndcX = clip.X / clip.W;
float ndcY = clip.Y / clip.W;
float screenX = (ndcX * 0.5f + 0.5f) * viewport.X;
float screenY = (1f - (ndcY * 0.5f + 0.5f)) * viewport.Y;
// Screen-space radius. projection.M22 = cot(fovY/2). clip.W is
// the camera-space distance.
float scaleY = projection.M22;
if (scaleY <= 0f) return false;
float screenRadius = worldRadius * scaleY * viewport.Y / (2f * clip.W);
// Cull obviously-off-screen entities (more than a screen away).
if (screenX + screenRadius < -viewport.X || screenX - screenRadius > 2f * viewport.X) return false;
if (screenY + screenRadius < -viewport.Y || screenY - screenRadius > 2f * viewport.Y) return false;
// Floor at minSidePixels so distant entities still get a visible /
// clickable rect. The picker must apply the same floor as the
// indicator or distant clicks won't match the visible bracket.
if (screenRadius < minSidePixels * 0.5f) screenRadius = minSidePixels * 0.5f;
rectMin = new Vector2(screenX - screenRadius, screenY - screenRadius);
rectMax = new Vector2(screenX + screenRadius, screenY + screenRadius);
return true;
}
}

View file

@ -158,4 +158,91 @@ public static class WorldPicker
}
return bestGuid;
}
/// <summary>
/// 2026-05-16. Screen-space rect-hit-test picker overload. Each
/// candidate's world-space sphere (via <paramref name="sphereForEntity"/>)
/// projects to a screen-space rectangle through
/// <see cref="ScreenProjection.TryProjectSphereToScreenRect"/>. The
/// rect is inflated by <paramref name="inflatePixels"/> on every side
/// (matches the indicator's <c>TriangleSize</c> outer brackets) and
/// hit-tested against the mouse pixel. Among rects that contain the
/// mouse, the entity with the nearest camera-space depth wins.
///
/// <para>
/// Why screen-space instead of world-space ray-sphere: the indicator
/// draws a screen-space RECT. A world-space sphere projects to a
/// screen CIRCLE inscribed in that rect — leaving the four rect
/// corners as click dead zones. Per user feedback 2026-05-16, the
/// click area must match the visible indicator extent exactly. By
/// sharing the <see cref="ScreenProjection"/> helper with
/// <c>TargetIndicatorPanel</c>, the click rect and the drawn rect
/// cannot drift.
/// </para>
///
/// <para>
/// Resolver returning <c>null</c> skips the candidate (matches retail
/// "no Setup → not pickable" behavior). Entities with
/// <c>ServerGuid == 0</c> (atlas-tier scenery) and the player's own
/// guid are also skipped.
/// </para>
///
/// <para>
/// Stage A of the picker port. Stage B (polygon refine via
/// <c>CPolygon::polygon_hits_ray</c> 0x0054c889) remains deferred
/// per issue #71 — only needed if visual testing surfaces a Stage A
/// over-pick on entities whose visible mesh is well inside the
/// indicator rect.
/// </para>
/// </summary>
/// <param name="inflatePixels">Pixel inflate on each side of the
/// projected rect. Pass the indicator's <c>TriangleSize</c> (8 px)
/// so the click area extends to where the visible bracket corners
/// sit — the user perceives the inflated rect as the clickable area.</param>
public static uint? Pick(
float mouseX, float mouseY,
Matrix4x4 view,
Matrix4x4 projection,
Vector2 viewport,
IEnumerable<WorldEntity> candidates,
uint skipServerGuid,
Func<WorldEntity, (Vector3 CenterWorld, float Radius)?> sphereForEntity,
float inflatePixels = 8f)
{
uint? bestGuid = null;
float bestDepth = float.PositiveInfinity;
foreach (var entity in candidates)
{
if (entity.ServerGuid == 0u) continue;
if (entity.ServerGuid == skipServerGuid) continue;
var sphere = sphereForEntity(entity);
if (sphere is null) continue;
var (center, radius) = sphere.Value;
if (radius <= 0f) continue;
if (!ScreenProjection.TryProjectSphereToScreenRect(
center, radius, view, projection, viewport,
out var rMin, out var rMax, out var depth))
continue;
// Inflate by inflatePixels on each side — extend hit area to
// where the indicator brackets sit.
float minX = rMin.X - inflatePixels;
float minY = rMin.Y - inflatePixels;
float maxX = rMax.X + inflatePixels;
float maxY = rMax.Y + inflatePixels;
if (mouseX < minX || mouseX > maxX) continue;
if (mouseY < minY || mouseY > maxY) continue;
if (depth < bestDepth)
{
bestDepth = depth;
bestGuid = entity.ServerGuid;
}
}
return bestGuid;
}
}