feat(#184): Slice 2b — unify the remote player/NPC fork (players collide faithfully)
Collapse the two-path fork in RemotePhysicsUpdater.Tick: the former Path A (grounded PLAYER remotes advanced by the interp catch-up with ResolveWithTransition deliberately OMITTED, per the now-retired issue-#40 premise) is gone. Every remote -- player and NPC -- now runs the SAME per-tick catch-up + sweep + shadow-follows-resolved. Retail's UpdateObjectInternal (0x005156b0) has no player/remote fork; this is the faithful shape. Player remotes now get terrain-Z snap (no slope staircase), wall collision, and MONSTER collision -- previously (Path A) they skipped ALL collision. RETAIL PvP (adversarial review caught this): two non-PK players WALK THROUGH each other in AC (you can stand inside another non-PK player) -- they do NOT de-overlap. The remote-player mover now carries IsPlayer|EdgeSlide (mirroring the LOCAL player at PlayerMovementController), so CollisionExemption's PvP block exempts a non-PK pair, exactly as retail sets IsPlayer on every object's own transition (OBJECTINFO::init 0x0050cf30) and FindObjCollisions (pc:276812) exempts it. The first 2b draft passed bare EdgeSlide and de-overlapped players (MORE solid than retail); the 3-lens review flagged it. PK/PKLite/Impenetrable are not plumbed onto the remote mover yet -- the same M1.5 gap the local player carries (TS-23, extended). #40 proven dead in code (before the gate): PlayerVsMonster_DeOverlapsAndAbsorbsTheStallBlip drives the real ComputeOffset -> InterpolationManager catch-up (incl. the fail_count blip-to-tail) for a player mover converging on a monster and asserts de-overlap + maxSpike<0.30 -- the sweep absorbs the stall-blip. ConvergingPlayers_WalkThroughEachOther proves the PvP exemption (non-PK players pass through). Path B already ran the #40-feared config stably; #40 (May 2026) predates the CSphere/#137/#170/#171 rebuild. Placement (HIGH, review finding 3): the player UP routing gains the SAME placement-snap backstop Slice 1 gave NPCs (AP-87). Without it a UM-first player (RemoteMotion seeded to the spawn pos, then a first UP in a different cell) would sweep from a stale cell -> garbage -> the digest's invisible/misplaced-player bug. The 4 m bodyToTarget guard + !willBeDrTicked + dist>96 snap; near placed corrections still enqueue for smooth catch-up. Also seed Body.Position=worldPos at UP-handler RemoteMotion creation (mirrors the UM handler :5176) for the UP-first case. Coupled shadow edits (research finding 9): RETIRED the players-only raw-worldPos shadow sync -- now that players run the sweep + shadow-follows-resolved, the raw sync would re-snap a packed player's shadow into overlap each UP. Player shadows follow the RESOLVED body via the DR-tick loop + a new player UP-branch-tail SyncRemoteShadowToBody. Surviving player/NPC split (AP-88): the omega -- grounded PLAYERS keep the ObservedOmega-or-seqOmega world-frame (Concatenate) fallback ("rectangle when running circles"); NPCs + airborne keep ObservedOmega-only body-frame (Multiply). They commute for an upright body + yaw omega, so the fork is faithful. Register: TS-23 extended (remote-player mover PK gap); AP-86 updated (raw sync retired for players too, Where column fixed); AP-88 added (omega fork + eval-order note). Tests: Core 2623 / App 741 green, 0 warnings. 3-lens adversarial review + per-finding verification (10 agents); all 6 confirmed findings addressed (2 substantive: PvP mover flags + player placement-snap; 4 doc/cosmetic). VISUAL GATE (acceptance test) owed by the user -- NOTE the corrected expectation: (a) a player remote on a hill -- no slope staircase; (b) two packed player remotes -- they WALK THROUGH each other (retail PvP), NOT de-overlap (this corrects the design's original "players de-overlap" gate); (c) a player remote cannot stand inside a MONSTER (new: player-vs-monster collision); (d) remote walk/run/jump/land/turn UNCHANGED. Handoff: docs/research/2026-07-07-184-slice2-unify-extract-handoff.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e1ac56cce9
commit
ddb5a96799
4 changed files with 303 additions and 309 deletions
|
|
@ -12,12 +12,16 @@ namespace AcDream.App.Physics;
|
|||
/// (<c>ae.Sequencer != null && serverGuid != 0 && serverGuid != _playerServerGuid
|
||||
/// && rm.LastServerPosTime > 0</c>).
|
||||
///
|
||||
/// <para>Behaviour is byte-for-byte the pre-extraction body: the player/NPC
|
||||
/// FORK is PRESERVED here unchanged — Path A (grounded PLAYER remotes,
|
||||
/// <c>IsPlayerGuid(serverGuid) && !rm.Airborne</c>) advances by the interp
|
||||
/// catch-up and deliberately OMITS the sweep; Path B (NPCs + airborne player
|
||||
/// remotes) runs <c>ResolveWithTransition</c> + shadow-follows-resolved. Slice
|
||||
/// 2b collapses the fork inside this class. See
|
||||
/// <para>Slice 2a extracted this verbatim (fork intact). Slice 2b then COLLAPSED
|
||||
/// the player/NPC fork: the former Path A (grounded PLAYER remotes advanced by the
|
||||
/// interp catch-up with the sweep deliberately OMITTED, per the now-retired issue
|
||||
/// #40 premise) is gone — <b>every</b> remote now runs the SAME catch-up +
|
||||
/// <c>ResolveWithTransition</c> sweep + shadow-follows-resolved, so packed PLAYER
|
||||
/// remotes de-overlap exactly like NPCs (retail <c>UpdateObjectInternal</c>
|
||||
/// 0x005156b0 has no player/remote fork). The only surviving player/NPC split is
|
||||
/// the omega handling (players keep the <c>ObservedOmega||seqOmega</c> world-frame
|
||||
/// fallback; NPCs + airborne bodies use <c>ObservedOmega</c>-only body-frame) and
|
||||
/// the <c>!IsPlayerGuid</c>-gated stale-velocity anim-cycle stop. See
|
||||
/// <c>docs/research/2026-07-07-184-slice2-unify-extract-handoff.md</c>.</para>
|
||||
///
|
||||
/// <para>Shared helpers that GameWindow also calls elsewhere are injected:
|
||||
|
|
@ -72,269 +76,21 @@ internal sealed class RemotePhysicsUpdater
|
|||
// TickRemoteMoveTo). No-op for entities with no target + no voyeurs.
|
||||
rm.Host?.HandleTargetting();
|
||||
|
||||
if (IsPlayerGuid(serverGuid) && !rm.Airborne)
|
||||
// #184 Slice 2b — the UNIFIED per-remote tick. The former Path A
|
||||
// (grounded PLAYER remotes: interp catch-up with the ResolveWithTransition
|
||||
// sweep OMITTED, per the now-retired issue-#40 "collision is the sender's
|
||||
// problem" premise) is GONE — every remote now runs the SAME catch-up +
|
||||
// sweep + shadow-follows-resolved, so packed PLAYER remotes de-overlap
|
||||
// exactly like NPCs. Retail's UpdateObjectInternal (0x005156b0) has NO
|
||||
// player/remote fork; the only surviving player/NPC split is the omega
|
||||
// handling (Step 2 below) and the !IsPlayerGuid-gated anim-cycle stop.
|
||||
//
|
||||
// Stop detection stays explicit on packet receipt (UpdateMotion
|
||||
// ForwardCommand cleared -> Ready; UpdatePosition HasVelocity cleared ->
|
||||
// StopCompletely). Mirrors retail update_object -> UpdatePositionInternal
|
||||
// -> UpdatePhysicsInternal (FUN_00515020 / FUN_00513730 / FUN_005111D0).
|
||||
// The bare block scopes this update's locals (formerly the else body).
|
||||
{
|
||||
// ── L.3 M2/M3 (2026-05-05): queue + anim chase for grounded player remotes ──
|
||||
//
|
||||
// Per retail spec (docs/research/2026-05-04-l3-port/01-per-tick.md +
|
||||
// 04-interp-manager.md +
|
||||
// 05-position-manager-and-partarray.md):
|
||||
//
|
||||
// - For a grounded REMOTE player, m_velocityVector stays at 0.
|
||||
// - apply_current_movement is NEVER called per tick on remotes
|
||||
// (it's the local-player-only velocity feed).
|
||||
// - UpdatePhysicsInternal's translation step is gated on
|
||||
// velocity² > 0, so it's a no-op when body.Velocity = 0.
|
||||
// - ResolveWithTransition is NOT called — the server already
|
||||
// collision-resolved the broadcast position.
|
||||
// - Per-tick body translation per retail UpdatePositionInternal:
|
||||
// 1. CPartArray::Update writes anim root motion (body-local
|
||||
// seqVel × dt) into the local frame.
|
||||
// 2. PositionManager::adjust_offset OVERWRITES the local
|
||||
// frame's origin with the queue catch-up vector when
|
||||
// the queue is active and the head is not yet reached
|
||||
// — REPLACE, not additive.
|
||||
// 3. Frame::combine composes the local frame with the
|
||||
// body's world pose.
|
||||
// Net: catch-up replaces anim during the chase phase, anim
|
||||
// stands when the queue is empty / head reached. PositionManager.
|
||||
// ComputeOffset implements this exact REPLACE dichotomy.
|
||||
//
|
||||
// Airborne player remotes (rm.Airborne) and NPCs fall through to
|
||||
// the legacy path below — unchanged from main per the M2 plan.
|
||||
System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity
|
||||
?? System.Numerics.Vector3.Zero;
|
||||
System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega
|
||||
?? System.Numerics.Vector3.Zero;
|
||||
|
||||
// Step 1: transient flags (Contact + OnWalkable for grounded;
|
||||
// Active always so UpdatePhysicsInternal doesn't early-return).
|
||||
if (!rm.Airborne)
|
||||
{
|
||||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
||||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
||||
| AcDream.Core.Physics.TransientStateFlags.Active;
|
||||
|
||||
// For grounded remotes the body should not be carrying
|
||||
// velocity — retail's m_velocityVector for a remote is
|
||||
// 0 unless the server explicitly pushed one. Clear any
|
||||
// stale velocity from a prior airborne arc so
|
||||
// UpdatePhysicsInternal doesn't double-apply it on top
|
||||
// of the seqVel-driven ComputeOffset translation below.
|
||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
|
||||
}
|
||||
|
||||
// R4-V5 glide fix (2026-07-03 user report: a retail
|
||||
// player using an object GLIDES to it — position moves
|
||||
// via the UP queue but no walk/run legs): remote
|
||||
// PLAYERS' MoveToManagers were armed by mt-6 but never
|
||||
// TICKED — the V4 UseTime slot lived only in the
|
||||
// NPC/legacy branch below, gated !IsPlayerGuid
|
||||
// (inherited from the deleted RemoteMoveToDriver's
|
||||
// NPC-only scope). Retail ticks every entity's
|
||||
// MovementManager (UpdateObjectInternal has no entity-
|
||||
// class fork). The manager's dispatches produce the
|
||||
// locomotion cycle through the funnel sink (the LEGS);
|
||||
// position stays queue-chased per the L.3 M2 spec, so
|
||||
// the two compose exactly like an NPC's tick.
|
||||
TickRemoteMoveTo(rm);
|
||||
|
||||
// Step 2 (M3): queue + anim translation via PositionManager.
|
||||
// ComputeOffset returns:
|
||||
// - Vector3.Zero when queue is empty AND seqVel is zero
|
||||
// (idle remote between UPs after head reached) — body
|
||||
// stays still.
|
||||
// - Direction × min(catchUpSpeed × dt, dist) when the
|
||||
// queue is active and head is not reached — body chases
|
||||
// the head waypoint at up to 2× motion-table max speed
|
||||
// (REPLACES anim for this frame).
|
||||
// - Anim root motion (seqVel × dt rotated into world) when
|
||||
// the queue is empty OR head is within DesiredDistance —
|
||||
// body advances with the locomotion cycle's baked
|
||||
// velocity, keeping legs and body pace synchronized.
|
||||
// - Blip-to-tail (tail − body) when fail_count > 3.
|
||||
float maxSpeed = rm.Motion.GetMaxSpeed();
|
||||
// Slope-staircase fix (2026-05-05): sample terrain normal
|
||||
// at the body's current XY so PositionManager can project
|
||||
// the seqVel-only fallback onto the local slope. Without
|
||||
// this, the queue-empty interval between UPs left Z flat
|
||||
// (anim cycles bake Z=0 body-local) — visible ~5 Hz
|
||||
// staircase when a remote runs up/down hills. The
|
||||
// projection is a no-op on flat ground.
|
||||
System.Numerics.Vector3? terrainNormal = _physicsEngine.SampleTerrainNormal(
|
||||
rm.Body.Position.X, rm.Body.Position.Y);
|
||||
|
||||
System.Numerics.Vector3 bodyPosBefore = rm.Body.Position;
|
||||
System.Numerics.Vector3 offset = rm.Position.ComputeOffset(
|
||||
dt: (double)dt,
|
||||
currentBodyPosition: rm.Body.Position,
|
||||
seqVel: seqVel,
|
||||
ori: rm.Body.Orientation,
|
||||
interp: rm.Interp,
|
||||
maxSpeed: maxSpeed,
|
||||
terrainNormal: terrainNormal);
|
||||
// R5-V3 (#171): retail chains Interpolation → Sticky over
|
||||
// ONE shared delta frame (PositionManager::adjust_offset
|
||||
// 0x00555190). The combiner's catch-up IS acdream's
|
||||
// interpolation stage, so its offset SEEDS the frame
|
||||
// (converted to mover-local) and StickyManager::
|
||||
// adjust_offset OVERWRITES it when armed+initialized
|
||||
// (0x00555430 assigns m_fOrigin rather than accumulating).
|
||||
// With no stick armed the frame comes back untouched and
|
||||
// this reduces to the pre-V3 `Position += offset`.
|
||||
if (rm.Host is { } plHost)
|
||||
{
|
||||
var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
|
||||
{
|
||||
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
|
||||
rm.Body.Orientation, offset),
|
||||
};
|
||||
plHost.PositionManager.AdjustOffset(pmDelta, dt);
|
||||
ApplyPositionManagerDelta(rm.Body, pmDelta);
|
||||
}
|
||||
else
|
||||
{
|
||||
rm.Body.Position += offset;
|
||||
}
|
||||
|
||||
// Slope-staircase diagnostic — gated on ACDREAM_SLOPE_DIAG=1.
|
||||
// Prints per-tick body Z trajectory + queue state + projected
|
||||
// offset.Z so we can grep before/after the fix and confirm Z
|
||||
// changes continuously between UPs on slopes (no flat
|
||||
// intervals followed by snaps).
|
||||
if (System.Environment.GetEnvironmentVariable("ACDREAM_SLOPE_DIAG") == "1")
|
||||
{
|
||||
bool queueActive = rm.Interp.IsActive;
|
||||
float nz = terrainNormal?.Z ?? 1.0f;
|
||||
System.Console.WriteLine(
|
||||
$"[SLOPE] guid={serverGuid:X8} bodyZ={bodyPosBefore.Z:F3}->{rm.Body.Position.Z:F3} "
|
||||
+ $"offset=({offset.X:F3},{offset.Y:F3},{offset.Z:F3}) "
|
||||
+ $"queue={queueActive} cpN.Z={nz:F3}");
|
||||
}
|
||||
|
||||
// Step 2.5: angular velocity → body orientation. Prefer
|
||||
// ObservedOmega (set explicitly in OnLiveMotionUpdated from
|
||||
// the wire's TurnCommand + signed TurnSpeed) over the
|
||||
// sequencer's synthesized omega: when the player runs in
|
||||
// a circle ACE broadcasts ForwardCommand=RunForward AND
|
||||
// TurnCommand=TurnLeft on the same UpdateMotion. The
|
||||
// sequencer's animCycle picker chooses RunForward (legs
|
||||
// running), whose synthesized CurrentOmega is zero. Body
|
||||
// would not rotate between UPs and body.Velocity stays in
|
||||
// an out-of-date world direction, producing the
|
||||
// user-reported "rectangle when running circles" effect.
|
||||
// ObservedOmega has the correct turn rate even when the
|
||||
// visible cycle is RunForward.
|
||||
System.Numerics.Vector3 omegaToApply =
|
||||
rm.ObservedOmega.LengthSquared() > 1e-9f
|
||||
? rm.ObservedOmega
|
||||
: seqOmega;
|
||||
if (omegaToApply.LengthSquared() > 1e-9f)
|
||||
{
|
||||
float angleDelta = omegaToApply.Length() * (float)dt;
|
||||
System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply);
|
||||
var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta);
|
||||
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
|
||||
System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot));
|
||||
|
||||
// Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): print seqOmega direction
|
||||
// once per remote per ~1 second so we can confirm whether the omega
|
||||
// sign actually being applied matches the retail-observed turn
|
||||
// direction. Z>0 = CCW (TurnLeft); Z<0 = CW (TurnRight).
|
||||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||||
{
|
||||
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
if (nowSec - rm.LastOmegaDiagLogTime > 0.5)
|
||||
{
|
||||
uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
|
||||
System.Console.WriteLine(
|
||||
$"[OMEGA_DIAG] guid={serverGuid:X8} motion=0x{seqMotion:X8} "
|
||||
+ $"omegaApplied.Z={omegaToApply.Z:F3} "
|
||||
+ $"(seq.Z={seqOmega.Z:F3} obs.Z={rm.ObservedOmega.Z:F3}) "
|
||||
+ $"(Z>0=CCW=TurnLeft, Z<0=CW=TurnRight)");
|
||||
rm.LastOmegaDiagLogTime = nowSec;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: calc_acceleration sets body.Acceleration from the Gravity flag
|
||||
// (mirrors retail CPhysicsObj::calc_acceleration @ FUN_00511420, called
|
||||
// per-frame in update_object). Without this, body.Acceleration stays stale
|
||||
// or zero → gravity never decays jump velocity → endless rise on jumps.
|
||||
rm.Body.calc_acceleration();
|
||||
|
||||
// Step 4: physics integration (Euler: pos += vel*dt + 0.5*accel*dt²).
|
||||
rm.Body.UpdatePhysicsInternal(dt);
|
||||
|
||||
// Step 4b INTENTIONALLY OMITTED in M2:
|
||||
// ResolveWithTransition is NOT called — the server has
|
||||
// already collision-resolved the broadcast position, and
|
||||
// running our sweep on tiny per-frame queue catch-up deltas
|
||||
// amplifies micro-bounces into visible position blips
|
||||
// (issue #40 staircase + flat-ground blips). Per retail
|
||||
// spec the per-tick body advance for a remote is purely
|
||||
// the queue catch-up; collision is the sender's problem.
|
||||
//
|
||||
// Step 5 (landing fallback) is unreachable in this branch —
|
||||
// we're gated on !rm.Airborne. Airborne player remotes fall
|
||||
// through to the legacy path below where K-fix15 still fires.
|
||||
|
||||
// Step 6: speed-overshoot diagnostic (ACDREAM_REMOTE_VEL_DIAG=1).
|
||||
// Track the maximum sequencer velocity magnitude seen since
|
||||
// the last UpdatePosition arrival (carried on the
|
||||
// RemoteMotion struct), then on each UP arrival the
|
||||
// OnLivePositionUpdated path prints the comparison against
|
||||
// the server's actual broadcast pace
|
||||
// ((LastServerPos - PrevServerPos) / Δt). This guarantees
|
||||
// both sides are sampled during the same window and the
|
||||
// ratio reflects the real overshoot.
|
||||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||||
{
|
||||
// body.Velocity is now the source of bulk translation
|
||||
// (set above by apply_current_movement). Track its
|
||||
// magnitude so VEL_DIAG can compare against the actual
|
||||
// server broadcast pace.
|
||||
float seqSpeedNow = rm.Body.Velocity.Length();
|
||||
if (seqSpeedNow > rm.MaxSeqSpeedSinceLastUP)
|
||||
rm.MaxSeqSpeedSinceLastUP = seqSpeedNow;
|
||||
}
|
||||
|
||||
ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
|
||||
if (rm.CellId != 0)
|
||||
ae.Entity.ParentCellId = rm.CellId;
|
||||
ae.Entity.Rotation = rm.Body.Orientation;
|
||||
}
|
||||
else
|
||||
{
|
||||
// ── LEGACY PATH (UNCHANGED — kept until Task 8 cleanup) ──
|
||||
//
|
||||
// Stop detection is handled explicitly on packet receipt:
|
||||
// - UpdateMotion with ForwardCommand flag CLEARED → Ready.
|
||||
// - UpdatePosition with HasVelocity flag CLEARED → StopCompletely.
|
||||
// Both map to retail's "flag-absent = Invalid = reset to
|
||||
// default" semantics (FUN_0051F260 bulk-copy). No timer-based
|
||||
// inference needed — the server sends the right signal every
|
||||
// time a remote stops.
|
||||
|
||||
// Retail per-tick motion pipeline applied to every remote.
|
||||
// Mirrors retail FUN_00515020 update_object → FUN_00513730
|
||||
// UpdatePositionInternal → FUN_005111D0 UpdatePhysicsInternal:
|
||||
//
|
||||
// 1. apply_current_movement (FUN_00529210) — recomputes
|
||||
// body.Velocity from InterpretedState via get_state_velocity.
|
||||
// 2. Pull omega from the sequencer (baked MotionData.Omega
|
||||
// for TurnRight / TurnLeft cycles, scaled by speedMod).
|
||||
// 3. body.update_object(now) — Euler-integrates
|
||||
// position += Velocity × dt + 0.5 × Accel × dt² AND
|
||||
// orientation += omega × dt.
|
||||
//
|
||||
// On UpdatePosition receipt we hard-snap body.Position and
|
||||
// body.Orientation — if integration matched server physics,
|
||||
// each snap is small/invisible.
|
||||
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
|
||||
// Step 1: re-apply current motion commands → body.Velocity.
|
||||
|
|
@ -367,8 +123,8 @@ internal sealed class RemotePhysicsUpdater
|
|||
// (get_state_velocity / SERVERVEL Body.Velocity = ServerVelocity):
|
||||
// retail's UpdateObjectInternal (0x005156b0) has NO synth-velocity
|
||||
// leg — a remote translates by adjust_offset and the UP is a gentle
|
||||
// target. Matches Path A's grounded model (:10113); clearing
|
||||
// velocity mirrors Path A (:10125).
|
||||
// target. As of #184 Slice 2b this grounded model is the SINGLE
|
||||
// remote path (players + NPCs) — retail has no fork.
|
||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||
|
||||
// Stale server-velocity → stop the locomotion CYCLE (the legs).
|
||||
|
|
@ -427,8 +183,37 @@ internal sealed class RemotePhysicsUpdater
|
|||
// ObservedOmega derived from server UP rotation deltas so
|
||||
// the rate exactly matches server physics — hard-snap on
|
||||
// next UP becomes invisible by construction.
|
||||
rm.Body.Omega = System.Numerics.Vector3.Zero; // don't double-integrate in update_object
|
||||
if (rm.ObservedOmega.LengthSquared() > 1e-8f)
|
||||
// #184 Slice 2b: PLAYERS keep the ObservedOmega||seqOmega fallback +
|
||||
// world-frame (pre-multiply, Concatenate) application inherited from the
|
||||
// former Path A — a circling player sends RunForward+TurnLeft on ONE UM
|
||||
// whose RunForward cycle synthesises zero omega, so ObservedOmega (from
|
||||
// the wire TurnCommand) must carry the turn or the body would not rotate
|
||||
// between UPs ("rectangle when running circles"). NPCs + AIRBORNE bodies
|
||||
// keep ObservedOmega-only, body-frame (post-multiply, Multiply) — a
|
||||
// seqOmega fallback would change NPC turning (handoff 4.1), so the split
|
||||
// is preserved. For an upright body + a yaw (world-Z) omega the two
|
||||
// multiplication orders commute, so this fork is faithful, not cosmetic.
|
||||
// calc_acceleration zeroes Body.Omega for grounded bodies before
|
||||
// UpdatePhysicsInternal; the explicit zero here covers the airborne case
|
||||
// (a wire-set Body.Omega would otherwise double-integrate on top of the
|
||||
// manual rotation).
|
||||
rm.Body.Omega = System.Numerics.Vector3.Zero;
|
||||
if (IsPlayerGuid(serverGuid) && !rm.Airborne)
|
||||
{
|
||||
System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega
|
||||
?? System.Numerics.Vector3.Zero;
|
||||
System.Numerics.Vector3 omegaToApply =
|
||||
rm.ObservedOmega.LengthSquared() > 1e-9f ? rm.ObservedOmega : seqOmega;
|
||||
if (omegaToApply.LengthSquared() > 1e-9f)
|
||||
{
|
||||
float angleDelta = omegaToApply.Length() * (float)dt;
|
||||
System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply);
|
||||
var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta);
|
||||
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
|
||||
System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot));
|
||||
}
|
||||
}
|
||||
else if (rm.ObservedOmega.LengthSquared() > 1e-8f)
|
||||
{
|
||||
float omegaMag = rm.ObservedOmega.Length();
|
||||
var axis = rm.ObservedOmega / omegaMag;
|
||||
|
|
@ -580,10 +365,26 @@ internal sealed class RemotePhysicsUpdater
|
|||
// we hit on the local jump).
|
||||
isOnGround: !rm.Airborne,
|
||||
body: rm.Body, // persist ContactPlane across frames for slope tracking
|
||||
// Retail default physics state includes EdgeSlide.
|
||||
// Remote dead-reckoning should exercise the same
|
||||
// edge/cliff branch as local movement.
|
||||
moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
|
||||
// Retail default physics state includes EdgeSlide; remote DR
|
||||
// should exercise the same edge/cliff branch as local movement.
|
||||
// #184 Slice 2b: a remote PLAYER mover ALSO carries IsPlayer, so
|
||||
// CollisionExemption's PvP block fires exactly as it does for the
|
||||
// LOCAL player (PlayerMovementController :920) — two non-PK players
|
||||
// WALK THROUGH each other (retail sets IsPlayer on every object's
|
||||
// own transition via OBJECTINFO::init 0x0050cf30 `state |= 0x100`
|
||||
// from its weenie IsPlayer(); FindObjCollisions pc:276812 exempts a
|
||||
// non-PK player pair). Without IsPlayer the mover would de-overlap
|
||||
// two players — MORE solid than retail (you can stand inside another
|
||||
// non-PK player in AC). Players still COLLIDE with monsters (target
|
||||
// not IsPlayer → no exemption) + terrain + walls. PK/PKLite/
|
||||
// Impenetrable are NOT plumbed onto the remote mover yet, so a PK
|
||||
// pair walks through where retail collides — the SAME M1.5 gap the
|
||||
// local player carries (see TS-23; PlayerDescription PK status
|
||||
// unparsed).
|
||||
moverFlags: IsPlayerGuid(serverGuid)
|
||||
? AcDream.Core.Physics.ObjectInfoState.IsPlayer
|
||||
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide
|
||||
: AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
|
||||
// Fix #42 (2026-05-05): skip the moving remote's
|
||||
// own ShadowEntry. _animatedEntities is keyed by
|
||||
// entity.Id so kv.Key matches the EntityId the
|
||||
|
|
@ -613,9 +414,10 @@ internal sealed class RemotePhysicsUpdater
|
|||
// de-overlap PERSIST and keeps collision == render. Re-flood is cheap
|
||||
// MOVEMENT-GATED (#184 review): re-flood only when the resolved
|
||||
// body moved > ~1 cm since the last shadow registration. This is
|
||||
// SAFE now that the per-UP :5669 sync is players-only — the NPC
|
||||
// shadow's only writers are this loop + the UP-branch tail, both to
|
||||
// the resolved body, so a net-stationary (de-overlapped, sweep-
|
||||
// SAFE now that #184 Slice 2b RETIRED the per-UP raw-pos sync for
|
||||
// players too — every remote's shadow (player + NPC) is written ONLY
|
||||
// by this loop + the UP-branch tail, both to the resolved body, so a
|
||||
// net-stationary (de-overlapped, sweep-
|
||||
// blocked) creature keeps its correct shadow and never re-floods,
|
||||
// while a moving/de-overlapping crowd (which moves every tick) still
|
||||
// syncs every tick. Bounds the per-tick RegisterMultiPart flood cost
|
||||
|
|
|
|||
|
|
@ -5654,21 +5654,17 @@ public sealed class GameWindow : IDisposable
|
|||
// (its body is the simulator, not a target). Retail does the
|
||||
// equivalent via SetPosition → change_cell → AddShadowObject
|
||||
// (acclient_2013_pseudo_c.txt:284276 / 281200 / 282862).
|
||||
if (update.Guid != _playerServerGuid && IsPlayerGuid(update.Guid))
|
||||
{
|
||||
// Remote PLAYERS only (#184): a player remote (Path A, no sweep) tracks
|
||||
// the server position closely, so syncing its shadow to the raw pos is
|
||||
// fine. NPC shadows are synced to the RESOLVED body instead — by the
|
||||
// NPC-branch tail below and the per-tick DR loop (SyncRemoteShadowToBody)
|
||||
// — because an NPC's body is de-overlapped BEHIND its raw server pos;
|
||||
// writing the raw pos here would snap the shadow into overlap for a frame
|
||||
// each UP and fight the de-overlap (the #184 review's :5669 finding).
|
||||
// BR-7: the wire position's full cell id seeds the re-flood
|
||||
// (retail SetPosition → calc_cross_cells from m_position).
|
||||
_physicsEngine.ShadowObjects.UpdatePosition(
|
||||
entity.Id, worldPos, rot, origin.X, origin.Y, p.LandblockId,
|
||||
seedCellId: p.LandblockId);
|
||||
}
|
||||
// #184 Slice 2b: the former players-only RAW-pos shadow sync is RETIRED.
|
||||
// It was a Slice-1 stopgap while grounded player remotes (old Path A) skipped
|
||||
// the sweep and tracked the server position closely. Now that Slice 2b runs
|
||||
// the SAME per-tick sweep + shadow-follows-resolved for players, writing the
|
||||
// raw (overlapping) server pos here would re-snap a packed player's shadow
|
||||
// into overlap once per UP and fight the in-tick de-overlap (research
|
||||
// finding 9). Player shadows now follow the RESOLVED body — via the DR-tick
|
||||
// loop (SyncRemoteShadowToBody, movement-gated) and the player UP-branch tail
|
||||
// below (first-UP / no-Sequencer case), exactly like NPCs. Local-player
|
||||
// broadphase still tests an up-to-date remote shadow; it is just the resolved
|
||||
// body now, not the raw wire pos.
|
||||
|
||||
// Track remote-entity motion for stop detection. Only record the
|
||||
// timestamp when position moved MEANINGFULLY (> 0.05m). Updates
|
||||
|
|
@ -5710,6 +5706,18 @@ public sealed class GameWindow : IDisposable
|
|||
// Hard-snap orientation on first spawn so the per-tick
|
||||
// slerp doesn't visibly rotate from Identity to truth.
|
||||
rmState.Body.Orientation = rot;
|
||||
// #184 Slice 2b: PLACE the body at the server position on creation,
|
||||
// mirroring the UM handler's seed (:5176 `Body.Position =
|
||||
// entity.Position`). A UP-first RemoteMotion (created here before any
|
||||
// UM) was left at the default (0,0,0). Path A never swept, so that
|
||||
// stale origin was harmless — it caught up gradually. Now that Slice
|
||||
// 2b runs the sweep for grounded PLAYERS too, an unplaced body would
|
||||
// sweep from (0,0,0) in the server cell that does not contain it →
|
||||
// garbage resolved pos → the digest's INVISIBLE/misplaced-body bug.
|
||||
// Seeding here is the root-cause fix (the UP creation path should
|
||||
// seed exactly like the UM path); worldPos == entity.Position (the
|
||||
// unconditional snap at the top of this handler already ran).
|
||||
rmState.Body.Position = worldPos;
|
||||
}
|
||||
|
||||
// L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for
|
||||
|
|
@ -5819,25 +5827,45 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
// ── GROUNDED ROUTING (CPhysicsObj::MoveOrTeleport) ────────────
|
||||
const float MaxPhysicsDistance = 96f;
|
||||
const float MaxPhysicsDistance = 96f; // retail player_distance far-snap
|
||||
const float BodySnapThreshold = 4f; // large correction / teleport / unplaced -> snap
|
||||
var localPlayerPos = _playerController?.Position ?? System.Numerics.Vector3.Zero;
|
||||
float dist = System.Numerics.Vector3.Distance(worldPos, localPlayerPos);
|
||||
// #184 Slice 2b: the player UP routing gains the SAME placement-snap
|
||||
// backstop the NPC routing got in Slice 1 (AP-87). Now that grounded
|
||||
// PLAYER remotes run the sweep, an unplaced / stale-cell body — a
|
||||
// UM-first RemoteMotion seeded to the spawn pos (:5176) then a first UP
|
||||
// in a DIFFERENT cell, which the UP-creation seed (:5720) does NOT cover
|
||||
// — would enqueue and the per-tick sweep would run from a cell that does
|
||||
// not contain the body -> garbage resolved pos -> the digest's
|
||||
// INVISIBLE/misplaced player. The 4 m bodyToTarget guard is the
|
||||
// LOAD-BEARING backstop (AP-87; firstUp via LastServerPosTime is a poor
|
||||
// signal for players — it is already set by the VEL_DIAG block above);
|
||||
// !willBeDrTicked snaps a no-Sequencer player whose queue nothing would
|
||||
// consume; dist>96 is retail's far-snap. Placed + near corrections still
|
||||
// enqueue for the smooth catch-up.
|
||||
float bodyToTarget = System.Numerics.Vector3.Distance(
|
||||
rmState.Body.Position, worldPos);
|
||||
bool willBeDrTicked =
|
||||
_animatedEntities.TryGetValue(entity.Id, out var aeDrPl)
|
||||
&& aeDrPl.Sequencer is not null;
|
||||
|
||||
if (dist > MaxPhysicsDistance)
|
||||
if (dist > MaxPhysicsDistance || !willBeDrTicked
|
||||
|| bodyToTarget > BodySnapThreshold)
|
||||
{
|
||||
// Beyond view bubble: SetPositionSimple slide-snap. Clear queue.
|
||||
// Beyond view bubble / large correction / unplaced body:
|
||||
// SetPositionSimple slide-snap. Clear queue.
|
||||
rmState.Interp.Clear();
|
||||
rmState.Body.Position = worldPos;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Within view bubble: enqueue waypoint for adjust_offset to walk to.
|
||||
// The per-frame TickAnimations player-remote path drives the
|
||||
// actual body advancement via InterpolationManager.AdjustOffset.
|
||||
// Pass body's current position so the InterpolationManager can
|
||||
// detect a far-distance enqueue (>100 m from body) and pre-arm
|
||||
// an immediate blip — avoids body drifting visibly toward a
|
||||
// far waypoint instead of teleporting to it.
|
||||
// Within view bubble, placed + near: enqueue waypoint for
|
||||
// adjust_offset to walk to. The per-frame TickAnimations player-
|
||||
// remote path drives the actual body advancement via
|
||||
// InterpolationManager.AdjustOffset. Pass body's current position so
|
||||
// the InterpolationManager can detect a far-distance enqueue (>100 m
|
||||
// from body) and pre-arm an immediate blip.
|
||||
float headingFromQuat = ExtractYawFromQuaternion(rot);
|
||||
rmState.Interp.Enqueue(
|
||||
worldPos,
|
||||
|
|
@ -5871,6 +5899,18 @@ public sealed class GameWindow : IDisposable
|
|||
// the near-enqueue branch this prevents a 1-frame teleport-then-
|
||||
// yank-back rubber-band as TickAnimations chases worldPos via the
|
||||
// queue.
|
||||
//
|
||||
// #184 Slice 2b: sync the player-remote shadow to the RESOLVED/placed
|
||||
// body (mirrors the NPC UP-branch tail). Now that grounded players run
|
||||
// the DR-tick sweep + shadow-follows-resolved, the retired raw-pos sync
|
||||
// (top of this handler) would have re-snapped the shadow into overlap
|
||||
// each UP; this keeps collision == render for the first UP (before any
|
||||
// DR tick) and for no-Sequencer players. rmState.CellId is the server
|
||||
// cell adopted above (:5735). The per-tick loop keeps it current
|
||||
// between UPs (movement-gated).
|
||||
if (rmState.CellId != 0)
|
||||
_remotePhysicsUpdater.SyncRemoteShadowToBody(
|
||||
entity.Id, rmState, _liveCenterX, _liveCenterY);
|
||||
entity.SetPosition(rmState.Body.Position);
|
||||
return;
|
||||
}
|
||||
|
|
@ -6105,8 +6145,8 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
// #184: sync the NPC shadow to the resolved/placed body (NOT the raw
|
||||
// server pos — the :5669 sync is players-only now) so collision ==
|
||||
// render and the de-overlap isn't snapped away for a frame each UP.
|
||||
// server pos — the raw-pos sync was RETIRED for players too in Slice 2b)
|
||||
// so collision == render and the de-overlap isn't snapped away each UP.
|
||||
// Covers the first UP (before any DR tick) and no-Sequencer NPCs (which
|
||||
// the per-tick loop skips). The per-tick loop keeps it current between
|
||||
// UPs, movement-gated. rmState.CellId is the server cell adopted above.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue