diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index c7a5ca44..866becb7 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -178,43 +178,51 @@ internal sealed class LiveEntityNetworkUpdateController height = 1.835f; } - ResolveResult placement = _physicsEngine.ResolvePlacement( + var moverFlags = IsPlayerGuid(serverGuid) + ? AcDream.Core.Physics.ObjectInfoState.IsPlayer + | AcDream.Core.Physics.ObjectInfoState.EdgeSlide + : AcDream.Core.Physics.ObjectInfoState.EdgeSlide; + + // Retail's spawn contact comes from the FIRST GRAVITY FRAME, not the + // placement itself: every retail CPhysicsObj simulates, so a freshly + // placed creature falls the few centimetres onto the floor and the + // transition's touch grants the contact plane. Our stationary remotes + // never run a physics frame (the DR tick resolves only movers), so + // the settle is compressed here: a short downward sweep from the + // server position. Its touch handler produces exactly the state + // retail's first frame would (position snapped onto the floor, + // contact plane + CONTACT/ON_WALKABLE committed below). A sweep that + // finds no floor (true airborne spawn) leaves the body airborne. + AcDream.Core.Physics.ResolveResult settle = _physicsEngine.ResolveWithTransition( worldPos, + worldPos + new System.Numerics.Vector3(0f, 0f, -0.5f), cellId, - radius, - height, + sphereRadius: radius, + sphereHeight: height, stepUpHeight: 0.4f, stepDownHeight: 0.4f, - moverFlags: IsPlayerGuid(serverGuid) - ? AcDream.Core.Physics.ObjectInfoState.IsPlayer - | AcDream.Core.Physics.ObjectInfoState.EdgeSlide - : AcDream.Core.Physics.ObjectInfoState.EdgeSlide, + isOnGround: false, + body: remote.Body, + moverFlags: moverFlags, movingEntityId: entity.Id); if (AcDream.Core.Physics.PhysicsDiagnostics.DumpMotionEnabled) { Console.WriteLine( - $"[SPAWN-PLACE] guid={serverGuid:X8} cell=0x{cellId:X8} ok={placement.Ok} " - + $"contact={placement.InContact} walkable={placement.OnWalkable}"); + $"[SPAWN-PLACE] guid={serverGuid:X8} cell=0x{cellId:X8} ok={settle.Ok} " + + $"contact={settle.InContact} walkable={settle.OnWalkable} " + + $"z={worldPos.Z:F3}->{settle.Position.Z:F3}"); } - if (!placement.Ok) - return; // unplaceable — stays airborne, like a failed retail placement + if (!settle.Ok || !settle.InContact) + return; // no floor within reach — stays airborne like retail's fall - remote.Body.Position = placement.Position; - remote.Body.ContactPlaneValid = placement.InContact; - if (placement.InContact) - { - remote.Body.ContactPlane = placement.ContactPlane; - remote.Body.ContactPlaneCellId = placement.ContactPlaneCellId; - remote.Body.ContactPlaneIsWater = placement.ContactPlaneIsWater; - remote.Body.GroundNormal = placement.ContactPlane.Normal; - } + remote.Body.Position = settle.Position; AcDream.Core.Physics.PhysicsObjUpdate.CommitSetPositionTransition( remote.Body, - placement.InContact, - placement.OnWalkable, - placement.CollisionNormalValid, - placement.CollisionNormal, + settle.InContact, + settle.OnWalkable, + settle.CollisionNormalValid, + settle.CollisionNormal, previousContact: false, previousOnWalkable: false, remote.Movement.HitGround, @@ -727,17 +735,19 @@ internal sealed class LiveEntityNetworkUpdateController remote.Body.Orientation = entity.Rotation; remote.Body.Position = entity.Position; } - // #270: run the retail spawn placement so the body has real ground + // #270: run the retail spawn settle so the body has real ground // contact BEFORE the funnel below dispatches this packet's actions — // an attack swing needs contact_allows_move true to animate. Retried - // (not creation-only) while the body has never been successfully - // placed: creation during the login flood can precede streaming - // residency or the entity's cell hydration, and retail's own answer - // to "object addressed before its cell exists" is the - // CObjectMaint lost-cell list — park it, re-place when the cell is - // available. The no-contact-and-no-plane predicate is exactly - // "never placed"; one success ends the retries. - if (!remote.Body.InContact && !remote.Body.ContactPlaneValid) + // (not creation-only) while the body lacks the CONTACT transient + // (the flag contact_allows_move reads — NOT ContactPlaneValid, which + // a DR writeback can set from last-known plane data without real + // contact): creation during the login flood can precede streaming + // residency or cell hydration, and retail's own answer to "object + // addressed before its cell exists" is the CObjectMaint lost-cell + // list — park it, re-place when the cell is available. One grounded + // settle ends the retries; a genuinely airborne remote (mid-jump + // player) fails the floor probe harmlessly until it lands. + if (!remote.Body.InContact) { SeedRemoteSpawnPlacement( remote,