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:
parent
e2bc3a9e99
commit
b5da17db76
10 changed files with 1348 additions and 573 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue