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;
}
}