feat(#184): remote-creature de-overlap — placement-snap + shadow-follows-resolved (visual gate passed)
Packed monster remotes interpenetrate in acdream but barely in retail on the same ACE. Retail de-overlaps them CLIENT-side: it sweeps every remote creature every tick against neighbours' LIVE resolved positions (the collision shadow == the resolved m_position, re-registered every moved transition step), with the server position a gentle catch-up target (CPhysicsObj::MoveOrTeleport 0x00516330), not a hard-snap. The collision math was already faithful; the bug was the reconciliation (hard-snap) + the movement model (synth-velocity) + a stale shadow. A first attempt (reverted) enqueued EVERYTHING and left the shadow at the raw server position — it gate-failed with invisible monsters (an unplaced body blipped over a huge distance into the sweep -> garbage pos) and the player stuck on offset shadows. This redo fixes both root causes, mechanism-proven in a Core test first: - NPC UpdatePosition routes through MoveOrTeleport with a PLACEMENT-SNAP: the body is snapped to the server pos when it is not already near it (first UP, no Sequencer to consume the queue, >96 m, or |Body - worldPos| > 4 m); only near DR corrections enqueue. This restores the body's placement authority (no invisible monsters). Airborne keeps the authoritative hard-snap. - Grounded movement drives the body from the interp CATCH-UP (ComputeOffset -> InterpolationManager::adjust_offset, REPLACE dichotomy) instead of synth-velocity (get_state_velocity / SERVERVEL); MovementManager::UseTime runs unconditionally. - SHADOW-FOLLOWS-RESOLVED: after each tick's sweep the collision shadow is re-registered at the resolved body (SyncRemoteShadowToBody), movement-gated (|Body - LastShadowSyncPos| > 1 cm). The per-UP :5669 raw-pos sync is now PLAYERS-ONLY, so an NPC's shadow is only ever written to its resolved body -> neighbours de-overlap against resolved bodies, the spread PERSISTS, and collision == render (no stuck-on-nothing). Landing clears the interp queue. Preserved: airborne path, sticky #171 (gate + StickyManager overwrite of the seeded frame), omega, the #173 bounce, landing, the node_fail_counter watchdog, and Path A (player remotes, untouched -- Slice 2 unifies it). Tests (RemoteDeOverlapMechanismTests): converging pair settles STABLE at 0.86 m (barely overlapping = the retail look) WITH the shadow-sync vs <0.40 m (full overlap) WITHOUT it; a third test drives the REAL InterpolationManager loop and confirms the sweep absorbs the stall-blip (no pop-into-neighbour). 2-lens Opus review (CONCERNS) addressed: movement-gated re-flood for the town-FPS risk; players-only :5669; the blip-absorption test. Register: retires TS-41 (SERVERVEL synth-velocity -> catch-up), narrows TS-44 (NPC UP unified onto the interp queue; gate kept for orientation), adds AP-86 (shadow-follows-resolved impl) + AP-87 (MoveOrTeleport 4 m/no-Sequencer placement snap). Known residual: the de-overlap sweep uses the human sphere for the mover, so large creatures de-overlap at human radii (TS-46; Slice 3 plumbs Setup dims). Visual gate PASSED (user: monsters visible + spacing much better). Core 2620 / App 741 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7f7a78d3ea
commit
37a94e1fa4
4 changed files with 558 additions and 99 deletions
|
|
@ -463,6 +463,15 @@ public sealed class GameWindow : IDisposable
|
|||
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
|
||||
public System.Numerics.Vector3 LastServerPos;
|
||||
/// <summary>
|
||||
/// #184: the body position at which this remote's collision SHADOW was
|
||||
/// last (re-)registered. The per-tick shadow-follows-resolved sync
|
||||
/// (<c>SyncRemoteShadowToBody</c>) skips re-flooding when the body has
|
||||
/// not moved > ε since this — a resting/idle-town crowd doesn't churn
|
||||
/// the registry, while a moving/de-overlapping crowd re-registers every
|
||||
/// tick. Sentinel <c>Zero</c> forces the first sync.
|
||||
/// </summary>
|
||||
public System.Numerics.Vector3 LastShadowSyncPos;
|
||||
/// <summary>
|
||||
/// Latest server-authoritative velocity for NPC/monster smoothing.
|
||||
/// Prefer the HasVelocity vector from UpdatePosition; when ACE omits
|
||||
/// it for a server-controlled creature, derive it from consecutive
|
||||
|
|
@ -4608,6 +4617,31 @@ public sealed class GameWindow : IDisposable
|
|||
+ delta.GetHeading());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #184 — shadow-follows-resolved. Re-register a remote creature's collision
|
||||
/// SHADOW at its RESOLVED body position, so OTHER creatures (and the player)
|
||||
/// de-overlap / collide against where the monster actually IS (== where it
|
||||
/// renders), not the raw overlapping server position. Retail re-registers a
|
||||
/// moved object's shadow every accepted transition step (SetPositionInternal
|
||||
/// → remove/add_shadows_to_cells, Ghidra 0x00515330); this is the acdream
|
||||
/// equivalent driven from the remote DR loop. worldOffset is recomputed from
|
||||
/// <paramref name="rm"/>.CellId + the streaming centre, exactly as the UP
|
||||
/// handler derives <c>origin</c> (:5618) — and is DEAD anyway when seedCellId
|
||||
/// is supplied (it only feeds the short-circuited DeriveOutdoorSeed). Updates
|
||||
/// <see cref="RemoteMotion.LastShadowSyncPos"/> so callers can movement-gate.
|
||||
/// </summary>
|
||||
private void SyncRemoteShadowToBody(uint entityId, RemoteMotion rm)
|
||||
{
|
||||
int shLbX = (int)((rm.CellId >> 24) & 0xFFu);
|
||||
int shLbY = (int)((rm.CellId >> 16) & 0xFFu);
|
||||
float shOffX = (shLbX - _liveCenterX) * 192f;
|
||||
float shOffY = (shLbY - _liveCenterY) * 192f;
|
||||
_physicsEngine.ShadowObjects.UpdatePosition(
|
||||
entityId, rm.Body.Position, rm.Body.Orientation,
|
||||
shOffX, shOffY, rm.CellId, seedCellId: rm.CellId);
|
||||
rm.LastShadowSyncPos = rm.Body.Position;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R5-V4: retail <c>CPhysicsObj::stick_to_object</c> (0x005127e0) — the
|
||||
/// mt-0 WIRE sticky (unpack_movement case 0 @00524589, gated on the
|
||||
|
|
@ -5662,8 +5696,15 @@ 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)
|
||||
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(
|
||||
|
|
@ -5923,7 +5964,69 @@ public sealed class GameWindow : IDisposable
|
|||
$"[sticky-snap-skip] guid=0x{update.Guid:X8} d={snapDist:F3} srv=({worldPos.X:F2},{worldPos.Y:F2}) body=({rmState.Body.Position.X:F2},{rmState.Body.Position.Y:F2})"));
|
||||
}
|
||||
if (!snapSuppressedByStick)
|
||||
rmState.Body.Position = worldPos;
|
||||
{
|
||||
// #184 (2026-07-07): retail CPhysicsObj::MoveOrTeleport (0x00516330)
|
||||
// for grounded NPC remotes. The body is PLACED (hard-snapped) whenever
|
||||
// it is not already tracking NEAR the server position — the first UP,
|
||||
// a large correction / teleport, an out-of-view creature (>96 m from
|
||||
// the local player), or an entity the DR loop won't tick — otherwise the
|
||||
// server point is a GENTLE dead-reckoning TARGET the per-tick interp
|
||||
// catch-up walks to, and the KEPT sweep de-overlaps that movement.
|
||||
//
|
||||
// The placement-snap is LOAD-BEARING: the earlier attempt (reverted)
|
||||
// enqueued EVERYTHING, so an unplaced body (origin, first UP) blipped
|
||||
// over a huge distance into the sweep -> a resolve started in a cell that
|
||||
// did not contain the body -> garbage resolved pos -> INVISIBLE monster
|
||||
// while its shadow (synced to server truth) stayed put -> player stuck on
|
||||
// nothing. Airborne keeps the authoritative hard-snap (arc integrates
|
||||
// locally, K-fix15). Physics digest 2026-07-07 banner.
|
||||
if (rmState.Airborne)
|
||||
{
|
||||
rmState.Body.Position = worldPos;
|
||||
}
|
||||
else
|
||||
{
|
||||
const float MaxPhysicsDistanceNpc = 96f; // retail player_distance far-snap
|
||||
const float BodySnapThresholdNpc = 4f; // large correction / teleport -> snap
|
||||
var localPlayerPosNpc = _playerController?.Position
|
||||
?? System.Numerics.Vector3.Zero;
|
||||
float distNpc = System.Numerics.Vector3.Distance(worldPos, localPlayerPosNpc);
|
||||
float bodyToTargetNpc = System.Numerics.Vector3.Distance(
|
||||
rmState.Body.Position, worldPos);
|
||||
// The 4 m bodyToTargetNpc guard is the LOAD-BEARING placement backstop:
|
||||
// it snaps any body not already near the target (an unplaced first-UP
|
||||
// body sits at the origin / spawn pos, far from worldPos). firstUpNpc is
|
||||
// a belt-and-suspenders hint only — it is NOT a reliable "never placed"
|
||||
// signal because a UM that enters a locomotion cycle can stamp
|
||||
// LastServerPosTime before the first UP (~:5340). Don't tune the 4 m
|
||||
// threshold down without re-checking the unplaced-body case.
|
||||
bool firstUpNpc = rmState.LastServerPosTime <= 0.0;
|
||||
// Enqueue only if the per-tick DR loop will CONSUME the queue (a
|
||||
// non-null Sequencer, the TickAnimations gate); else nothing walks
|
||||
// the body to the waypoint and it would freeze at its last pos.
|
||||
bool willBeDrTickedNpc =
|
||||
_animatedEntities.TryGetValue(entity.Id, out var aeDrNpc)
|
||||
&& aeDrNpc.Sequencer is not null;
|
||||
|
||||
if (firstUpNpc || !willBeDrTickedNpc
|
||||
|| distNpc > MaxPhysicsDistanceNpc
|
||||
|| bodyToTargetNpc > BodySnapThresholdNpc)
|
||||
{
|
||||
// Placement / far / large-correction: SNAP + clear queue.
|
||||
rmState.Interp.Clear();
|
||||
rmState.Body.Position = worldPos;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Near DR correction: enqueue the waypoint for the per-tick
|
||||
// catch-up (Path B consumes it via ComputeOffset).
|
||||
float headingNpc = ExtractYawFromQuaternion(rot);
|
||||
rmState.Interp.Enqueue(
|
||||
worldPos, headingNpc, isMovingTo: false,
|
||||
currentBodyPosition: rmState.Body.Position);
|
||||
}
|
||||
}
|
||||
}
|
||||
// K-fix15 (2026-04-26): DON'T auto-clear airborne on UP.
|
||||
// ACE broadcasts UPs during the arc (peak / mid-fall / land)
|
||||
// at ~5-10 Hz. The previous K-fix9 logic cleared Airborne on
|
||||
|
|
@ -6005,8 +6108,14 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
else if (!IsPlayerGuid(update.Guid) && rmState.HasServerVelocity
|
||||
&& !snapSuppressedByStick)
|
||||
&& !snapSuppressedByStick && rmState.Airborne)
|
||||
{
|
||||
// AIRBORNE-only (#184): a grounded NPC translates by the interp catch-up,
|
||||
// and the per-tick grounded loop zeroes Body.Velocity before integrating —
|
||||
// so this write is DEAD for grounded remotes and only muddies the
|
||||
// "ServerVelocity is diagnostic-only" contract. Gating it to airborne keeps
|
||||
// the arc's velocity re-sync and prevents synth velocity from leaking into a
|
||||
// grounded integrate.
|
||||
rmState.Body.Velocity = rmState.ServerVelocity;
|
||||
}
|
||||
|
||||
|
|
@ -6037,6 +6146,15 @@ public sealed class GameWindow : IDisposable
|
|||
rmState.ServerVelocity);
|
||||
}
|
||||
|
||||
// #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.
|
||||
// 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.
|
||||
if (rmState.CellId != 0)
|
||||
SyncRemoteShadowToBody(entity.Id, rmState);
|
||||
|
||||
entity.SetPosition(rmState.Body.Position);
|
||||
entity.Rotation = rmState.Body.Orientation;
|
||||
}
|
||||
|
|
@ -10356,43 +10474,30 @@ public sealed class GameWindow : IDisposable
|
|||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
||||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
||||
| AcDream.Core.Physics.TransientStateFlags.Active;
|
||||
// #170 residual fix (2026-07-04): an ARMED moveto always
|
||||
// takes the MOVETO branch. The old arbitration routed the
|
||||
// tick to SERVERVEL whenever UPs flowed (position-delta
|
||||
// synthesis sets HasServerVelocity=true for any moving
|
||||
// NPC), which SKIPPED MoveToManager.UseTime for exactly
|
||||
// the duration of the server-side chase — the armed
|
||||
// manager only ever ticked in UP-silent gaps (creature
|
||||
// stopped server-side), turned toward a stale heading,
|
||||
// and was interrupted by the next UM before reaching
|
||||
// BeginMoveForward. Live funnel (launch-drainq.log,
|
||||
// corrected per-guid attribution): 16 arms → 11 turns
|
||||
// dispatched → 1 run install; [npc-tick] shows
|
||||
// branch=SERVERVEL (skips UseTime) mtState=MoveToObject
|
||||
// for chasing scamps. The legs stayed in Ready while the
|
||||
// body glided on synthesized velocity — the #170 slide.
|
||||
// Retail runs MovementManager::UseTime UNCONDITIONALLY
|
||||
// per tick (CPhysicsObj::UpdateObjectInternal 0x005156b0,
|
||||
// call at 0x00515998) and has NO wire-velocity leg-driver;
|
||||
// between UPs a moveto-driven body translates from the
|
||||
// motion state (get_state_velocity) with UP hard-snaps
|
||||
// correcting drift. The SERVERVEL leg remains ONLY as the
|
||||
// legacy fallback for entities WITHOUT an armed moveto
|
||||
// (scripted-path NPCs / missiles carrying wire velocity).
|
||||
// Full-stack conformance: RemoteChaseEndToEndHarnessTests
|
||||
// (turn→run→drain sustained when the manager is ticked).
|
||||
|
||||
// #184 (2026-07-07): a grounded remote carries NO translation
|
||||
// velocity. Its per-tick movement is the interp CATCH-UP toward
|
||||
// the MoveOrTeleport-queued server waypoint (computed at the
|
||||
// sticky-compose site below), which the KEPT ResolveWithTransition
|
||||
// sweep de-overlaps against neighbours — and the resolved position
|
||||
// is written back into the SHADOW (below) so the de-overlap
|
||||
// persists and neighbours collide against the resolved body, not
|
||||
// the raw server pos. This REPLACES the old synth-velocity model
|
||||
// (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).
|
||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||
|
||||
// Stale server-velocity → stop the locomotion CYCLE (the legs).
|
||||
// ANIM ONLY — translation is the catch-up. Kept verbatim (same
|
||||
// !moveToArmed && !stickyArmed gate) from the old SERVERVEL branch
|
||||
// so a scripted-path NPC that stops server-side drops out of its
|
||||
// walk/run cycle; ApplyServerControlledVelocityCycle selects the
|
||||
// anim from ServerVelocity, independent of Body.Velocity.
|
||||
bool moveToArmed = rm.MoveTo is
|
||||
{ MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid };
|
||||
// R5-V3 (#171): a STUCK entity is also client-side-
|
||||
// driven — after the sticky arrival the moveto is
|
||||
// cleaned (Invalid) but StickyManager::adjust_offset
|
||||
// owns the between-snap translation exactly like an
|
||||
// armed moveto. Routing it to SERVERVEL would glide
|
||||
// the body on synthesized wire velocity AGAINST the
|
||||
// sticky steer (the same starvation class as the #170
|
||||
// SERVERVEL fix — register TS-41). Retail has no
|
||||
// SERVERVEL leg at all; it stays the fallback for
|
||||
// entities with NO client-side movement driver.
|
||||
bool stickyArmed =
|
||||
(rm.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u;
|
||||
if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity
|
||||
|
|
@ -10403,62 +10508,23 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
rm.ServerVelocity = System.Numerics.Vector3.Zero;
|
||||
rm.HasServerVelocity = false;
|
||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||
ApplyServerControlledVelocityCycle(
|
||||
serverGuid,
|
||||
ae,
|
||||
rm,
|
||||
System.Numerics.Vector3.Zero);
|
||||
}
|
||||
else
|
||||
{
|
||||
rm.Body.Velocity = rm.ServerVelocity;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// R4-V4: the retail MoveToManager drives
|
||||
// server-directed movement per tick — UseTime runs
|
||||
// HandleMoveToPosition/HandleTurnToHeading (steering
|
||||
// + arrival + fail-distance), dispatching its OWN
|
||||
// per-node locomotion (turn / RunForward) through the
|
||||
// sink. That per-node dispatch is the only motion the
|
||||
// per-tick update should issue for a remote.
|
||||
TickRemoteMoveTo(rm);
|
||||
|
||||
// #170 ROOT FIX (2026-07-04): the per-frame
|
||||
// rm.Motion.apply_current_movement(...) call that used
|
||||
// to be here is DELETED. For a remote (which has a
|
||||
// DefaultSink) it re-ran ApplyInterpretedMovement EVERY
|
||||
// FRAME, re-dispatching the whole interpreted state —
|
||||
// stance (0x8000003C), forward=attack, sidestep/turn
|
||||
// stops — and each successful DoInterpretedMotion
|
||||
// appends a CMotionInterp.pending_motions node. Those
|
||||
// nodes barely drain for a remote, so pending_motions
|
||||
// EXPLODED to ~1.3M entries (live capture: add=1.37M vs
|
||||
// done=5.7K; ~671K of them the unchanged stance).
|
||||
// MotionsPending() then stayed permanently true, so
|
||||
// MoveToManager.BeginTurnToHeading (0x00529b90,
|
||||
// `if (motions_pending) return`) could never start the
|
||||
// chase turn → the chase never reached BeginMoveForward
|
||||
// → RunForward, so the creature SLID in an idle+attack
|
||||
// pose instead of running. Retail dispatches per MOTION
|
||||
// EVENT (per UM), never per frame — a live cdb drain
|
||||
// trace showed retail add_to_queue == MotionDone (queue
|
||||
// stays shallow). The motion is already dispatched per
|
||||
// UM by the funnel (MoveToInterpretedState) and by the
|
||||
// MoveToManager per node above; the only thing the
|
||||
// deleted call still provided was body velocity, which
|
||||
// the get_state_velocity refresh below performs
|
||||
// directly. get_state_velocity (0x00527d50, verbatim)
|
||||
// returns WalkForward→3.12×spd, RunForward→4.0×spd, 0
|
||||
// otherwise; grounded-only (this branch already asserted
|
||||
// Contact+OnWalkable), airborne velocity owns the jump.
|
||||
if (rm.Body.OnWalkable)
|
||||
rm.Body.set_local_velocity(
|
||||
rm.Motion.get_state_velocity(),
|
||||
rm.Body.LastMoveWasAutonomous);
|
||||
}
|
||||
// R4-V4: tick the MoveToManager UNCONDITIONALLY (retail
|
||||
// MovementManager::UseTime per tick, UpdateObjectInternal call
|
||||
// @0x00515998) — UseTime runs HandleMoveToPosition /
|
||||
// HandleTurnToHeading (steering + arrival + fail-distance),
|
||||
// dispatching its per-node locomotion (turn / RunForward) through
|
||||
// the sink (the LEGS). Position comes from the catch-up; legs from
|
||||
// this per-node dispatch + the funnel. The #170-deleted per-frame
|
||||
// apply_current_movement is NOT reintroduced.
|
||||
TickRemoteMoveTo(rm);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -10510,20 +10576,72 @@ public sealed class GameWindow : IDisposable
|
|||
// here to prevent UpdatePhysicsInternal's own omega pass from
|
||||
// double-integrating.
|
||||
var preIntegratePos = rm.Body.Position;
|
||||
// R5-V3 (#171): the sticky steer — retail
|
||||
// UpdatePositionInternal's PositionManager::adjust_offset
|
||||
// slot (0x00512c30, call @0x00512d0e): the delta composes
|
||||
// into this tick's motion BEFORE UpdatePhysicsInternal +
|
||||
// the transition sweep below, so collision resolves the
|
||||
// sticky movement like any other motion (preIntegratePos
|
||||
// is captured first — the sweep covers the steer). No-op
|
||||
// when nothing is stuck (untouched delta frame).
|
||||
// R5-V3 (#171) + #184 (2026-07-07): retail chains Interpolation →
|
||||
// Sticky over ONE shared delta frame (PositionManager::adjust_offset
|
||||
// 0x00555190), composed BEFORE UpdatePhysicsInternal + the transition
|
||||
// sweep so collision resolves whichever movement won (preIntegratePos
|
||||
// captured first — the sweep covers it).
|
||||
// • GROUNDED: the interp CATCH-UP SEEDS the frame (world→local) —
|
||||
// the movement source is the adjust_offset walk toward the
|
||||
// MoveOrTeleport-queued server waypoint, exactly like Path A
|
||||
// (:10173). StickyManager::adjust_offset then OVERWRITES the
|
||||
// Origin when armed (0x00555430 ASSIGNS m_fOrigin — the REPLACE
|
||||
// dichotomy), so a stuck monster still steers via #171.
|
||||
// • AIRBORNE: seed an EMPTY frame (no catch-up — the arc integrates
|
||||
// from velocity + gravity, unchanged).
|
||||
// Body.Velocity is 0 when grounded (set above), so UpdatePhysicsInternal
|
||||
// adds no translation on top of the catch-up — no double-move.
|
||||
if (rm.Host is { } npcHost)
|
||||
{
|
||||
var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta;
|
||||
if (!rm.Airborne)
|
||||
{
|
||||
System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
|
||||
?? System.Numerics.Vector3.Zero;
|
||||
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
||||
System.Numerics.Vector3? terrainNormalNpc =
|
||||
_physicsEngine.SampleTerrainNormal(
|
||||
rm.Body.Position.X, rm.Body.Position.Y);
|
||||
System.Numerics.Vector3 offsetNpc = rm.Position.ComputeOffset(
|
||||
dt: (double)dt,
|
||||
currentBodyPosition: rm.Body.Position,
|
||||
seqVel: seqVelNpc,
|
||||
ori: rm.Body.Orientation,
|
||||
interp: rm.Interp,
|
||||
maxSpeed: maxSpeedNpc,
|
||||
terrainNormal: terrainNormalNpc);
|
||||
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
|
||||
{
|
||||
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
|
||||
rm.Body.Orientation, offsetNpc),
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
|
||||
}
|
||||
npcHost.PositionManager.AdjustOffset(pmDelta, dt);
|
||||
ApplyPositionManagerDelta(rm.Body, pmDelta);
|
||||
}
|
||||
else if (!rm.Airborne)
|
||||
{
|
||||
// No PositionManager host yet (pre-binding): apply the catch-up
|
||||
// directly, matching Path A's fallback (:10202).
|
||||
System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
|
||||
?? System.Numerics.Vector3.Zero;
|
||||
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
||||
System.Numerics.Vector3? terrainNormalNpc =
|
||||
_physicsEngine.SampleTerrainNormal(
|
||||
rm.Body.Position.X, rm.Body.Position.Y);
|
||||
rm.Body.Position += rm.Position.ComputeOffset(
|
||||
dt: (double)dt,
|
||||
currentBodyPosition: rm.Body.Position,
|
||||
seqVel: seqVelNpc,
|
||||
ori: rm.Body.Orientation,
|
||||
interp: rm.Interp,
|
||||
maxSpeed: maxSpeedNpc,
|
||||
terrainNormal: terrainNormalNpc);
|
||||
}
|
||||
rm.Body.calc_acceleration();
|
||||
rm.Body.UpdatePhysicsInternal(dt);
|
||||
var postIntegratePos = rm.Body.Position;
|
||||
|
|
@ -10586,6 +10704,36 @@ public sealed class GameWindow : IDisposable
|
|||
if (resolveResult.CellId != 0)
|
||||
rm.CellId = resolveResult.CellId;
|
||||
|
||||
// #184 (2026-07-07) — SHADOW-FOLLOWS-RESOLVED (the load-bearing
|
||||
// de-overlap fix, proven in RemoteDeOverlapMechanismTests). Retail
|
||||
// re-registers a moved object's shadow every transition step
|
||||
// (SetPositionInternal → remove/add_shadows_to_cells, Ghidra
|
||||
// 0x00515330) so its m_position — the RESOLVED position — is what
|
||||
// OTHER creatures collide against. acdream's shadow otherwise only
|
||||
// syncs to the RAW server pos on UpdatePosition, so neighbours would
|
||||
// de-overlap against each other's OVERLAPPING shadows and any spread
|
||||
// would be discarded on the next UP (never accumulating), AND the
|
||||
// player would collide with a shadow offset from where the monster
|
||||
// renders (the reverted attempt's "stuck on an invisible monster").
|
||||
// Syncing the shadow to the resolved body every tick makes the
|
||||
// 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-
|
||||
// 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
|
||||
// to actually-moving remotes — the perf risk the review flagged for
|
||||
// a packed town. (In-place shadow-move + cell-relink-on-change is a
|
||||
// further optimization if profiling still shows churn.)
|
||||
if (System.Numerics.Vector3.DistanceSquared(
|
||||
rm.Body.Position, rm.LastShadowSyncPos) > 1e-4f)
|
||||
{
|
||||
SyncRemoteShadowToBody(kv.Key, rm);
|
||||
}
|
||||
|
||||
// #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
|
||||
// (pc:282699-282715) runs after EVERY SetPositionInternal —
|
||||
// remote objects included; a VectorUpdate-launched jump arc
|
||||
|
|
@ -10654,6 +10802,11 @@ public sealed class GameWindow : IDisposable
|
|||
&& rm.Body.Velocity.Z <= 0f)
|
||||
{
|
||||
rm.Airborne = false;
|
||||
// #184 (2026-07-07): clear the interp queue on landing (mirrors
|
||||
// the player-remote landing). Airborne UPs hard-snap and never
|
||||
// Enqueue, so any pre-jump waypoints are stale; without this the
|
||||
// first grounded catch-up after touchdown chases them backward.
|
||||
rm.Interp.Clear();
|
||||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
||||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
|
||||
rm.Body.Velocity = new System.Numerics.Vector3(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue