perf(physics): reuse reset-complete transition scratch
Mirror retail's ten-deep LIFO transition lifetime, retain all query scratch with complete reset contracts, and remove Tier-0 enum boxing without changing collision decisions. Fresh and retained engines are bit-identical across the expanded oracle, while measured transition profiles now allocate 0 bytes per resolve.
This commit is contained in:
parent
2effba5127
commit
16d182c2f0
19 changed files with 3037 additions and 1228 deletions
|
|
@ -6,7 +6,7 @@ namespace AcDream.Core.Physics;
|
|||
|
||||
internal readonly record struct TerrainWalkableSample(
|
||||
System.Numerics.Plane Plane,
|
||||
Vector3[] Vertices,
|
||||
TerrainTriangleVertices Vertices,
|
||||
float WaterDepth,
|
||||
bool IsWater,
|
||||
uint CellId);
|
||||
|
|
@ -27,6 +27,30 @@ internal readonly record struct TerrainWalkableSample(
|
|||
public sealed class PhysicsEngine
|
||||
{
|
||||
private readonly Dictionary<uint, LandblockPhysics> _landblocks = new();
|
||||
private readonly TransitionScratchArena? _transitionScratch;
|
||||
|
||||
public PhysicsEngine()
|
||||
: this(reuseTransitionScratch: true)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam for fresh-versus-reused transition differential evidence.
|
||||
/// Production always uses the public constructor and owns one retail-shaped
|
||||
/// scratch arena.
|
||||
/// </summary>
|
||||
internal PhysicsEngine(bool reuseTransitionScratch)
|
||||
{
|
||||
_transitionScratch = reuseTransitionScratch
|
||||
? new TransitionScratchArena()
|
||||
: null;
|
||||
}
|
||||
|
||||
private Transition RentTransition()
|
||||
=> _transitionScratch?.Rent() ?? new Transition();
|
||||
|
||||
private void ReturnTransition(Transition transition)
|
||||
=> _transitionScratch?.Return(transition);
|
||||
|
||||
/// <summary>Number of registered landblocks (diagnostic).</summary>
|
||||
public int LandblockCount => _landblocks.Count;
|
||||
|
|
@ -63,13 +87,13 @@ public sealed class PhysicsEngine
|
|||
int cx = (int)((cellOrLandblockId >> 24) & 0xFFu);
|
||||
int cy = (int)((cellOrLandblockId >> 16) & 0xFFu);
|
||||
for (int dx = -radius; dx <= radius; dx++)
|
||||
for (int dy = -radius; dy <= radius; dy++)
|
||||
{
|
||||
int nx = cx + dx, ny = cy + dy;
|
||||
if (nx < 0 || nx > 254 || ny < 0 || ny > 254) continue; // off-map: skip
|
||||
uint prefix = ((uint)nx << 24) | ((uint)ny << 16);
|
||||
if (!resident.Contains(prefix)) return false;
|
||||
}
|
||||
for (int dy = -radius; dy <= radius; dy++)
|
||||
{
|
||||
int nx = cx + dx, ny = cy + dy;
|
||||
if (nx < 0 || nx > 254 || ny < 0 || ny > 254) continue; // off-map: skip
|
||||
uint prefix = ((uint)nx << 24) | ((uint)ny << 16);
|
||||
if (!resident.Contains(prefix)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -195,13 +219,13 @@ public sealed class PhysicsEngine
|
|||
float localY = worldY - lb.WorldOffsetY;
|
||||
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
||||
{
|
||||
landblockId = kvp.Key;
|
||||
landblockId = kvp.Key;
|
||||
worldOffsetX = lb.WorldOffsetX;
|
||||
worldOffsetY = lb.WorldOffsetY;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
landblockId = 0;
|
||||
landblockId = 0;
|
||||
worldOffsetX = 0f;
|
||||
worldOffsetY = 0f;
|
||||
return false;
|
||||
|
|
@ -307,15 +331,10 @@ public sealed class PhysicsEngine
|
|||
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
||||
{
|
||||
var sample = lb.Terrain.SampleSurfacePolygon(localX, localY);
|
||||
var vertices = new Vector3[sample.Vertices.Length];
|
||||
for (int i = 0; i < sample.Vertices.Length; i++)
|
||||
{
|
||||
var v = sample.Vertices[i];
|
||||
vertices[i] = new Vector3(
|
||||
v.X + lb.WorldOffsetX,
|
||||
v.Y + lb.WorldOffsetY,
|
||||
v.Z);
|
||||
}
|
||||
var vertices = new TerrainTriangleVertices(
|
||||
OffsetTerrainVertex(sample.Vertices.V0, lb),
|
||||
OffsetTerrainVertex(sample.Vertices.V1, lb),
|
||||
OffsetTerrainVertex(sample.Vertices.V2, lb));
|
||||
|
||||
var normal = sample.Normal;
|
||||
float d = -Vector3.Dot(normal, vertices[0]);
|
||||
|
|
@ -337,6 +356,12 @@ public sealed class PhysicsEngine
|
|||
return null;
|
||||
}
|
||||
|
||||
private static Vector3 OffsetTerrainVertex(Vector3 vertex, LandblockPhysics landblock)
|
||||
=> new(
|
||||
vertex.X + landblock.WorldOffsetX,
|
||||
vertex.Y + landblock.WorldOffsetY,
|
||||
vertex.Z);
|
||||
|
||||
/// <summary>
|
||||
/// Indoor walking Phase 2 (2026-05-19). Resolves the cell id for a
|
||||
/// given world position via retail's portal-graph traversal for indoor
|
||||
|
|
@ -986,413 +1011,420 @@ public sealed class PhysicsEngine
|
|||
// the body BEFORE the engine mutates it so the replay test can seed its
|
||||
// PhysicsBody with the exact pre-call state. See PhysicsResolveCapture.cs.
|
||||
bool captureEnabled = PhysicsResolveCapture.IsEnabled
|
||||
&& moverFlags.HasFlag(ObjectInfoState.IsPlayer);
|
||||
&& (moverFlags & ObjectInfoState.IsPlayer) != 0;
|
||||
PhysicsBodySnapshot? bodyBeforeSnap =
|
||||
captureEnabled && body is not null
|
||||
? PhysicsResolveCapture.Snapshot(body)
|
||||
: null;
|
||||
|
||||
var transition = new Transition();
|
||||
transition.ObjectInfo.StepUpHeight = stepUpHeight;
|
||||
transition.ObjectInfo.StepDownHeight = stepDownHeight;
|
||||
transition.ObjectInfo.StepDown = true;
|
||||
// Fix #42 (2026-05-05): the moving entity's ShadowEntry must be
|
||||
// skipped in FindObjCollisions or the sweep collides with self.
|
||||
// Default 0 keeps tests / one-shot callers (no registered entity)
|
||||
// working. Plumbed through ObjectInfo because retail stores the
|
||||
// self pointer on OBJECTINFO::object (named-retail
|
||||
// acclient_2013_pseudo_c.txt:274435 OBJECTINFO::init →
|
||||
// this->object = arg2). The skip itself is at
|
||||
// CObjCell::find_obj_collisions line 308931.
|
||||
transition.ObjectInfo.SelfEntityId = movingEntityId;
|
||||
transition.ObjectInfo.MoverPhysicsState = body?.State ?? PhysicsStateFlags.None;
|
||||
transition.ObjectInfo.TargetId = designatedTargetId;
|
||||
|
||||
// Commit C 2026-04-29 — caller-supplied mover flags drive the
|
||||
// retail PvP exemption block in FindObjCollisions. The local
|
||||
// player passes IsPlayer (and PK/PKLite/Impenetrable when known
|
||||
// from PlayerDescription); remote dead-reckoning passes None
|
||||
// (matches non-player movement, all targets collide).
|
||||
transition.ObjectInfo.State |= moverFlags;
|
||||
|
||||
// CPhysicsObj::get_object_info 0x00511CC0: Missile contributes
|
||||
// PathClipped only. PerfectClip is deliberately not inferred.
|
||||
if (transition.ObjectInfo.MoverPhysicsState.HasFlag(PhysicsStateFlags.Missile))
|
||||
transition.ObjectInfo.State |= ObjectInfoState.PathClipped;
|
||||
|
||||
// frames_stationary_fall gate input: retail reads the mover's GRAVITY state bit
|
||||
// (object_info.object->state & 0x400, pc:272625). Seed it from the body so the ladder
|
||||
// in ValidateTransition runs for gravity movers (the player) and not floating props.
|
||||
transition.ObjectInfo.MoverHasGravity = body?.HasGravity ?? false;
|
||||
|
||||
if (isOnGround)
|
||||
transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
|
||||
|
||||
// K-fix7 (2026-04-26): only seed the contact plane when the body
|
||||
// is actually grounded. Pre-seeding while AIRBORNE caused
|
||||
// AdjustOffset's "Have a contact plane / Moving away from plane"
|
||||
// branch to fire on every jump step — which calls
|
||||
// Plane::snap_to_plane on the offset and ZEROES the Z component,
|
||||
// killing all upward jump motion.
|
||||
//
|
||||
// We KEEP the seeding when isOnGround for slope-walking + step-up
|
||||
// continuity (the original concern that motivated the seed).
|
||||
// BSP step_up needs ContactPlane on sub-step 1 to compute the
|
||||
// correct lift direction; removing the seed breaks stair-walking
|
||||
// at the last step (verified by A6.P3 slice 2 first attempt
|
||||
// 2026-05-22, reverted in this commit). Retail's CTransition::init
|
||||
// explicitly CLEARS contact_plane_valid; we deliberately diverge
|
||||
// for step_up correctness.
|
||||
//
|
||||
// A6.P3 slice 2 (2026-05-22) — to close issue #96 (per-tick CP-write
|
||||
// blowup) without breaking stair-walking, the no-op-if-unchanged
|
||||
// guard inside CollisionInfo.SetContactPlane (TransitionTypes.cs:259)
|
||||
// collapses redundant seeds (same plane every tick) to a true no-op.
|
||||
// The seed still fires the function call but only counts as a write
|
||||
// when the plane values actually change.
|
||||
if (isOnGround && body is not null && body.ContactPlaneValid)
|
||||
var transition = RentTransition();
|
||||
try
|
||||
{
|
||||
transition.CollisionInfo.SetContactPlane(
|
||||
body.ContactPlane,
|
||||
body.ContactPlaneCellId,
|
||||
body.ContactPlaneIsWater);
|
||||
}
|
||||
transition.ObjectInfo.StepUpHeight = stepUpHeight;
|
||||
transition.ObjectInfo.StepDownHeight = stepDownHeight;
|
||||
transition.ObjectInfo.StepDown = true;
|
||||
// Fix #42 (2026-05-05): the moving entity's ShadowEntry must be
|
||||
// skipped in FindObjCollisions or the sweep collides with self.
|
||||
// Default 0 keeps tests / one-shot callers (no registered entity)
|
||||
// working. Plumbed through ObjectInfo because retail stores the
|
||||
// self pointer on OBJECTINFO::object (named-retail
|
||||
// acclient_2013_pseudo_c.txt:274435 OBJECTINFO::init →
|
||||
// this->object = arg2). The skip itself is at
|
||||
// CObjCell::find_obj_collisions line 308931.
|
||||
transition.ObjectInfo.SelfEntityId = movingEntityId;
|
||||
transition.ObjectInfo.MoverPhysicsState = body?.State ?? PhysicsStateFlags.None;
|
||||
transition.ObjectInfo.TargetId = designatedTargetId;
|
||||
|
||||
// Retail CPhysicsObj::get_object_info also seeds SlidingNormal when
|
||||
// transient_state has bit 2 set. This matters for one-step/frame hits:
|
||||
// a wall collision at the end of one transition must project the next
|
||||
// frame's movement along the wall instead of hard-stopping again.
|
||||
if (body is not null
|
||||
&& body.TransientState.HasFlag(TransientStateFlags.Sliding)
|
||||
&& body.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
|
||||
{
|
||||
transition.CollisionInfo.SetSlidingNormal(body.SlidingNormal);
|
||||
}
|
||||
// Commit C 2026-04-29 — caller-supplied mover flags drive the
|
||||
// retail PvP exemption block in FindObjCollisions. The local
|
||||
// player passes IsPlayer (and PK/PKLite/Impenetrable when known
|
||||
// from PlayerDescription); remote dead-reckoning passes None
|
||||
// (matches non-player movement, all targets collide).
|
||||
transition.ObjectInfo.State |= moverFlags;
|
||||
|
||||
transition.SpherePath.InitPath(
|
||||
currentPos,
|
||||
targetPos,
|
||||
cellId,
|
||||
sphereRadius,
|
||||
sphereHeight,
|
||||
localSphereOrigin,
|
||||
beginOrientation,
|
||||
endOrientation);
|
||||
// CPhysicsObj::get_object_info 0x00511CC0: Missile contributes
|
||||
// PathClipped only. PerfectClip is deliberately not inferred.
|
||||
if ((transition.ObjectInfo.MoverPhysicsState & PhysicsStateFlags.Missile) != 0)
|
||||
transition.ObjectInfo.State |= ObjectInfoState.PathClipped;
|
||||
|
||||
// #145: supply the carried cell-relative frame anchor to the outdoor
|
||||
// membership pick. body.Position - body.CellPosition.Frame.Origin is the TRUE
|
||||
// landblock world origin, correct even for an UNSTREAMED neighbour — replacing
|
||||
// the terrain-registry origin that returns (0,0) and marches the cell id one
|
||||
// landblock per tick (the #145 far-town cascade). Engaged only for a SEEDED
|
||||
// OUTDOOR body whose carried landblock matches the resolve cell (the controller
|
||||
// passes body.CellPosition.ObjCellId for the outdoor case, so they agree);
|
||||
// null otherwise → legacy TryGetTerrainOrigin for NPCs/tests/indoor.
|
||||
transition.SpherePath.CarriedBlockOrigin =
|
||||
body is not null
|
||||
&& (cellId & 0xFFFFu) is >= 1u and <= 0x40u // resolve cell is an outdoor landcell
|
||||
&& (body.CellPosition.ObjCellId & 0xFFFFu) is >= 1u and <= 0x40u // carried cell is outdoor (seeded)
|
||||
&& (cellId >> 16) == (body.CellPosition.ObjCellId >> 16) // same landblock → anchor consistent
|
||||
? body.Position - body.CellPosition.Frame.Origin
|
||||
: null;
|
||||
// frames_stationary_fall gate input: retail reads the mover's GRAVITY state bit
|
||||
// (object_info.object->state & 0x400, pc:272625). Seed it from the body so the ladder
|
||||
// in ValidateTransition runs for gravity movers (the player) and not floating props.
|
||||
transition.ObjectInfo.MoverHasGravity = body?.HasGravity ?? false;
|
||||
|
||||
if (isOnGround && body is not null
|
||||
&& body.WalkablePolygonValid
|
||||
&& body.WalkableVertices is { Length: >= 3 })
|
||||
{
|
||||
transition.SpherePath.SetWalkable(
|
||||
body.WalkablePlane,
|
||||
body.WalkableVertices,
|
||||
body.WalkableUp);
|
||||
}
|
||||
if (isOnGround)
|
||||
transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
|
||||
|
||||
// Seed collision_info.frames_stationary_fall from the body's carried Stationary*
|
||||
// transient bits — retail transition() 0x00512dc0 seeds fsf from transient_state
|
||||
// 0x40/0x20/0x10 AFTER init_path and immediately BEFORE find_valid_position
|
||||
// (pc:280939-949). Placed here (post-InitPath) so InitPath's CollisionInfo reset
|
||||
// doesn't wipe the seed.
|
||||
if (body is not null)
|
||||
{
|
||||
transition.CollisionInfo.FramesStationaryFall =
|
||||
body.TransientState.HasFlag(TransientStateFlags.StationaryStuck) ? 3 :
|
||||
body.TransientState.HasFlag(TransientStateFlags.StationaryStop) ? 2 :
|
||||
body.TransientState.HasFlag(TransientStateFlags.StationaryFall) ? 1 : 0;
|
||||
}
|
||||
// K-fix7 (2026-04-26): only seed the contact plane when the body
|
||||
// is actually grounded. Pre-seeding while AIRBORNE caused
|
||||
// AdjustOffset's "Have a contact plane / Moving away from plane"
|
||||
// branch to fire on every jump step — which calls
|
||||
// Plane::snap_to_plane on the offset and ZEROES the Z component,
|
||||
// killing all upward jump motion.
|
||||
//
|
||||
// We KEEP the seeding when isOnGround for slope-walking + step-up
|
||||
// continuity (the original concern that motivated the seed).
|
||||
// BSP step_up needs ContactPlane on sub-step 1 to compute the
|
||||
// correct lift direction; removing the seed breaks stair-walking
|
||||
// at the last step (verified by A6.P3 slice 2 first attempt
|
||||
// 2026-05-22, reverted in this commit). Retail's CTransition::init
|
||||
// explicitly CLEARS contact_plane_valid; we deliberately diverge
|
||||
// for step_up correctness.
|
||||
//
|
||||
// A6.P3 slice 2 (2026-05-22) — to close issue #96 (per-tick CP-write
|
||||
// blowup) without breaking stair-walking, the no-op-if-unchanged
|
||||
// guard inside CollisionInfo.SetContactPlane (TransitionTypes.cs:259)
|
||||
// collapses redundant seeds (same plane every tick) to a true no-op.
|
||||
// The seed still fires the function call but only counts as a write
|
||||
// when the plane values actually change.
|
||||
if (isOnGround && body is not null && body.ContactPlaneValid)
|
||||
{
|
||||
transition.CollisionInfo.SetContactPlane(
|
||||
body.ContactPlane,
|
||||
body.ContactPlaneCellId,
|
||||
body.ContactPlaneIsWater);
|
||||
}
|
||||
|
||||
bool ok = transition.FindTransitionalPosition(this);
|
||||
// Retail CPhysicsObj::get_object_info also seeds SlidingNormal when
|
||||
// transient_state has bit 2 set. This matters for one-step/frame hits:
|
||||
// a wall collision at the end of one transition must project the next
|
||||
// frame's movement along the wall instead of hard-stopping again.
|
||||
if (body is not null
|
||||
&& (body.TransientState & TransientStateFlags.Sliding) != 0
|
||||
&& body.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
|
||||
{
|
||||
transition.CollisionInfo.SetSlidingNormal(body.SlidingNormal);
|
||||
}
|
||||
|
||||
var sp = transition.SpherePath;
|
||||
var ci = transition.CollisionInfo;
|
||||
transition.SpherePath.InitPath(
|
||||
currentPos,
|
||||
targetPos,
|
||||
cellId,
|
||||
sphereRadius,
|
||||
sphereHeight,
|
||||
localSphereOrigin,
|
||||
beginOrientation,
|
||||
endOrientation);
|
||||
|
||||
// Persist the resulting contact plane state back to the body so the
|
||||
// next frame's transition can seed from it. Uses LastKnownContactPlane
|
||||
// when current is invalid (e.g., airborne this frame), matching retail.
|
||||
if (body is not null)
|
||||
{
|
||||
// CPhysicsObj::transition 0x00512DC0 discards its CTransition when
|
||||
// find_valid_position fails. Only SetPositionInternal 0x00515330
|
||||
// publishes contact/fsf/walkable/sliding state, and that function
|
||||
// is unreachable on the UpdateObjectInternal failure branch.
|
||||
// #145: supply the carried cell-relative frame anchor to the outdoor
|
||||
// membership pick. body.Position - body.CellPosition.Frame.Origin is the TRUE
|
||||
// landblock world origin, correct even for an UNSTREAMED neighbour — replacing
|
||||
// the terrain-registry origin that returns (0,0) and marches the cell id one
|
||||
// landblock per tick (the #145 far-town cascade). Engaged only for a SEEDED
|
||||
// OUTDOOR body whose carried landblock matches the resolve cell (the controller
|
||||
// passes body.CellPosition.ObjCellId for the outdoor case, so they agree);
|
||||
// null otherwise → legacy TryGetTerrainOrigin for NPCs/tests/indoor.
|
||||
transition.SpherePath.CarriedBlockOrigin =
|
||||
body is not null
|
||||
&& (cellId & 0xFFFFu) is >= 1u and <= 0x40u // resolve cell is an outdoor landcell
|
||||
&& (body.CellPosition.ObjCellId & 0xFFFFu) is >= 1u and <= 0x40u // carried cell is outdoor (seeded)
|
||||
&& (cellId >> 16) == (body.CellPosition.ObjCellId >> 16) // same landblock → anchor consistent
|
||||
? body.Position - body.CellPosition.Frame.Origin
|
||||
: null;
|
||||
|
||||
if (isOnGround && body is not null
|
||||
&& body.WalkablePolygonValid
|
||||
&& body.WalkableVertices is { Length: >= 3 })
|
||||
{
|
||||
transition.SpherePath.SetWalkable(
|
||||
body.WalkablePlane,
|
||||
body.WalkableVertices,
|
||||
body.WalkableUp);
|
||||
}
|
||||
|
||||
// Seed collision_info.frames_stationary_fall from the body's carried Stationary*
|
||||
// transient bits — retail transition() 0x00512dc0 seeds fsf from transient_state
|
||||
// 0x40/0x20/0x10 AFTER init_path and immediately BEFORE find_valid_position
|
||||
// (pc:280939-949). Placed here (post-InitPath) so InitPath's CollisionInfo reset
|
||||
// doesn't wipe the seed.
|
||||
if (body is not null)
|
||||
{
|
||||
transition.CollisionInfo.FramesStationaryFall =
|
||||
(body.TransientState & TransientStateFlags.StationaryStuck) != 0 ? 3 :
|
||||
(body.TransientState & TransientStateFlags.StationaryStop) != 0 ? 2 :
|
||||
(body.TransientState & TransientStateFlags.StationaryFall) != 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
bool ok = transition.FindTransitionalPosition(this);
|
||||
|
||||
var sp = transition.SpherePath;
|
||||
var ci = transition.CollisionInfo;
|
||||
|
||||
// Persist the resulting contact plane state back to the body so the
|
||||
// next frame's transition can seed from it. Uses LastKnownContactPlane
|
||||
// when current is invalid (e.g., airborne this frame), matching retail.
|
||||
if (body is not null)
|
||||
{
|
||||
// CPhysicsObj::transition 0x00512DC0 discards its CTransition when
|
||||
// find_valid_position fails. Only SetPositionInternal 0x00515330
|
||||
// publishes contact/fsf/walkable/sliding state, and that function
|
||||
// is unreachable on the UpdateObjectInternal failure branch.
|
||||
if (ok)
|
||||
{
|
||||
if (ci.ContactPlaneValid)
|
||||
{
|
||||
body.ContactPlaneValid = true;
|
||||
body.ContactPlane = ci.ContactPlane;
|
||||
body.ContactPlaneCellId = ci.ContactPlaneCellId;
|
||||
body.ContactPlaneIsWater = ci.ContactPlaneIsWater;
|
||||
}
|
||||
else if (ci.LastKnownContactPlaneValid)
|
||||
{
|
||||
body.ContactPlaneValid = true;
|
||||
body.ContactPlane = ci.LastKnownContactPlane;
|
||||
body.ContactPlaneCellId = ci.LastKnownContactPlaneCellId;
|
||||
body.ContactPlaneIsWater = ci.LastKnownContactPlaneIsWater;
|
||||
}
|
||||
else
|
||||
{
|
||||
body.ContactPlaneValid = false;
|
||||
}
|
||||
|
||||
// Publish frames_stationary_fall + carry it to the next frame via the Stationary*
|
||||
// transient bits. Retail encodes these bits in handle_all_collisions (pc:282737-758);
|
||||
// acdream co-locates the encode with the fsf writeback here (STRUCTURAL ADAPTATION,
|
||||
// register) so the round-trip (seed→ladder→writeback→seed) is self-contained in Core.
|
||||
// handle_all_collisions (PhysicsObjUpdate) then only READS body.FramesStationaryFall.
|
||||
body.FramesStationaryFall = ci.FramesStationaryFall;
|
||||
body.TransientState &= ~(TransientStateFlags.StationaryFall
|
||||
| TransientStateFlags.StationaryStop
|
||||
| TransientStateFlags.StationaryStuck);
|
||||
body.TransientState |= ci.FramesStationaryFall switch
|
||||
{
|
||||
1 => TransientStateFlags.StationaryFall,
|
||||
2 => TransientStateFlags.StationaryStop,
|
||||
3 => TransientStateFlags.StationaryStuck,
|
||||
_ => TransientStateFlags.None,
|
||||
};
|
||||
|
||||
if (sp.HasLastWalkablePolygon && sp.LastWalkableVertices is not null)
|
||||
{
|
||||
body.WalkablePolygonValid = true;
|
||||
body.WalkablePlane = sp.LastWalkablePlane;
|
||||
body.SetWalkableVerticesExact(sp.LastWalkableVertices);
|
||||
body.WalkableUp = sp.LastWalkableUp;
|
||||
}
|
||||
else if (!isOnGround && !ci.ContactPlaneValid && !ci.LastKnownContactPlaneValid)
|
||||
{
|
||||
body.WalkablePolygonValid = false;
|
||||
body.WalkableVertices = null;
|
||||
}
|
||||
|
||||
// Retail persists sliding state to the body ONLY on transition
|
||||
// SUCCESS: CPhysicsObj::SetPositionInternal copies the normal at
|
||||
// 0x005154c2 and syncs SLIDING_TS (bit 4) from the transition's
|
||||
// final sliding_normal_valid at 0x005154e1 — and SetPositionInternal
|
||||
// is unreachable when find_valid_position fails (the transition is
|
||||
// discarded whole; the body keeps its prior state). #137 mechanism
|
||||
// 2: an unconditional writeback here could persist a normal retail
|
||||
// would discard.
|
||||
if (ci.SlidingNormalValid
|
||||
&& ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
|
||||
{
|
||||
body.SlidingNormal = ci.SlidingNormal;
|
||||
body.TransientState |= TransientStateFlags.Sliding;
|
||||
}
|
||||
else
|
||||
{
|
||||
body.SlidingNormal = Vector3.Zero;
|
||||
body.TransientState &= ~TransientStateFlags.Sliding;
|
||||
}
|
||||
}
|
||||
|
||||
// L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity.
|
||||
// Phase 3's reset path sets VelocityKilled when an airborne hit
|
||||
// can't find a walkable surface (steep roof, wall) AND the
|
||||
// body had a last_known_contact_plane (i.e., was grounded
|
||||
// recently). Retail zeros all three velocity components so
|
||||
// gravity restarts cleanly next frame.
|
||||
//
|
||||
// Named-retail: OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0)
|
||||
// acclient_2013_pseudo_c.txt:274467-274475
|
||||
// Called from CTransition::transitional_insert reset path:
|
||||
// acclient_2013_pseudo_c.txt:273237 (Phase 3)
|
||||
// acclient_2013_pseudo_c.txt:272567 (validate_transition)
|
||||
if (transition.ObjectInfo.VelocityKilled)
|
||||
{
|
||||
if (PhysicsDiagnostics.DumpSteepRoofEnabled)
|
||||
Console.WriteLine($"[steep-roof] KILL-VELOCITY-APPLIED Vbefore=({body.Velocity.X:F2},{body.Velocity.Y:F2},{body.Velocity.Z:F2}) → 0,0,0");
|
||||
body.Velocity = Vector3.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
// L.3a (2026-04-30): surface the wall normal so callers can apply
|
||||
// retail's velocity-reflection bounce (CPhysicsObj::handle_all_collisions
|
||||
// at acclient_2013_pseudo_c.txt:282699-282715, ACE PhysicsObj.cs:
|
||||
// 2692-2697). The reflection itself is applied in
|
||||
// PlayerMovementController after the position commit, gated on
|
||||
// apply_bounce = !(prevOnWalkable && newOnWalkable) — airborne wall
|
||||
// hits bounce, grounded wall slides don't.
|
||||
bool collisionNormalValid = ci.CollisionNormalValid;
|
||||
Vector3 collisionNormal = ci.CollisionNormal;
|
||||
|
||||
// #42 diagnostic (2026-05-05): trace airborne sweeps to identify the
|
||||
// source of the ~1m XY drift on retail-observed stationary jumps.
|
||||
// Gated on ACDREAM_AIRBORNE_DIAG=1 and !isOnGround. One line per
|
||||
// resolve call. deltaXY = post - target tells us how much the sweep
|
||||
// diverged from the requested target; for a clean stationary +Z
|
||||
// jump we expect (0,0). cp=valid with a tilted normal would confirm
|
||||
// H1 (initial-overlap depenetration → next-step AdjustOffset projects
|
||||
// the +Z offset along a non-+Z normal). User repros at flat plaza /
|
||||
// east hillside / north hillside; if drift direction tracks terrain
|
||||
// orientation, H1 is the cause; if it tracks actor facing, H2 / H3.
|
||||
if (!isOnGround
|
||||
&& Environment.GetEnvironmentVariable("ACDREAM_AIRBORNE_DIAG") == "1")
|
||||
{
|
||||
var post = sp.CheckPos;
|
||||
float dx = post.X - targetPos.X;
|
||||
float dy = post.Y - targetPos.Y;
|
||||
string cpInfo = ci.ContactPlaneValid
|
||||
? $"valid cpN=({ci.ContactPlane.Normal.X:F3},{ci.ContactPlane.Normal.Y:F3},{ci.ContactPlane.Normal.Z:F3})"
|
||||
: "none";
|
||||
Console.WriteLine(
|
||||
$"[SWEEP] airborne pre=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) " +
|
||||
$"target=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) " +
|
||||
$"post=({post.X:F3},{post.Y:F3},{post.Z:F3}) " +
|
||||
$"cell={cellId:X8}->{sp.CheckCellId:X8} ok={ok} " +
|
||||
$"deltaXY=({dx:F3},{dy:F3}) cp={cpInfo}");
|
||||
}
|
||||
|
||||
// L.2a slice 1 (2026-05-12): general-purpose resolver probe.
|
||||
// One line per call when PhysicsDiagnostics.ProbeResolveEnabled
|
||||
// is set (env var ACDREAM_PROBE_RESOLVE=1 at startup, or the
|
||||
// DebugPanel checkbox flipped at runtime). Captures every
|
||||
// dimension L.2 cares about: input/output position, input/output
|
||||
// cell, ok-vs-partial, grounded-in vs contact-out, contact-plane
|
||||
// status, wall normal if hit, walkable polygon valid. Zero cost
|
||||
// when off (one static-bool read).
|
||||
if (PhysicsDiagnostics.ProbeResolveEnabled)
|
||||
{
|
||||
var probePost = sp.CheckPos;
|
||||
string probeCp = ci.ContactPlaneValid
|
||||
? "valid"
|
||||
: (ci.LastKnownContactPlaneValid ? "lastKnown" : "none");
|
||||
string probeHit;
|
||||
if (collisionNormalValid)
|
||||
{
|
||||
// L.2a slice 2 (2026-05-12): include the hit object's guid +
|
||||
// environment flag so we can tell whether the wall is a building
|
||||
// (CBuildingObj), a door (CC0Cxxxx range), an NPC, or terrain.
|
||||
// Without this we know the wall normal but not the responsible
|
||||
// entity — half the L.2d sub-direction call.
|
||||
string objPart = ci.LastCollidedObjectGuid.HasValue
|
||||
? System.FormattableString.Invariant(
|
||||
$" obj=0x{ci.LastCollidedObjectGuid.Value:X8}")
|
||||
: "";
|
||||
string envPart = ci.CollidedWithEnvironment ? " env" : "";
|
||||
int objCount = ci.CollideObjectGuids.Count;
|
||||
string objCountPart = objCount > 1
|
||||
? System.FormattableString.Invariant($" nObj={objCount}")
|
||||
: "";
|
||||
probeHit = System.FormattableString.Invariant(
|
||||
$"yes n=({collisionNormal.X:F2},{collisionNormal.Y:F2},{collisionNormal.Z:F2}){objPart}{envPart}{objCountPart}");
|
||||
}
|
||||
else
|
||||
{
|
||||
probeHit = "no";
|
||||
}
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}"));
|
||||
}
|
||||
|
||||
// Phase W Stage 0 (2026-06-02): [cell-swept] probe — swept cell vs static-derived cell.
|
||||
// Emits before the ResolveResult is built so it shows what BOTH paths would return.
|
||||
// No ResolveCellId call here (it has a CellGraph.CurrCell side effect). No behavior change.
|
||||
if (PhysicsDiagnostics.ProbeSweptEnabled)
|
||||
{
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[cell-swept] ent=0x{movingEntityId:X8} ok={ok} inCell=0x{cellId:X8} curCell=0x{sp.CurCellId:X8} checkCell=0x{sp.CheckCellId:X8} curPos=({sp.CurPos.X:F3},{sp.CurPos.Y:F3},{sp.CurPos.Z:F3}) checkPos=({sp.CheckPos.X:F3},{sp.CheckPos.Y:F3},{sp.CheckPos.Z:F3})"));
|
||||
}
|
||||
|
||||
ResolveResult resolveResult;
|
||||
if (ok)
|
||||
{
|
||||
if (ci.ContactPlaneValid)
|
||||
{
|
||||
body.ContactPlaneValid = true;
|
||||
body.ContactPlane = ci.ContactPlane;
|
||||
body.ContactPlaneCellId = ci.ContactPlaneCellId;
|
||||
body.ContactPlaneIsWater = ci.ContactPlaneIsWater;
|
||||
}
|
||||
else if (ci.LastKnownContactPlaneValid)
|
||||
{
|
||||
body.ContactPlaneValid = true;
|
||||
body.ContactPlane = ci.LastKnownContactPlane;
|
||||
body.ContactPlaneCellId = ci.LastKnownContactPlaneCellId;
|
||||
body.ContactPlaneIsWater = ci.LastKnownContactPlaneIsWater;
|
||||
}
|
||||
else
|
||||
{
|
||||
body.ContactPlaneValid = false;
|
||||
}
|
||||
bool inContact = ci.ContactPlaneValid;
|
||||
bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
|
||||
inContact,
|
||||
ci.ContactPlane.Normal);
|
||||
bool onGround = inContact
|
||||
|| (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0;
|
||||
|
||||
// Publish frames_stationary_fall + carry it to the next frame via the Stationary*
|
||||
// transient bits. Retail encodes these bits in handle_all_collisions (pc:282737-758);
|
||||
// acdream co-locates the encode with the fsf writeback here (STRUCTURAL ADAPTATION,
|
||||
// register) so the round-trip (seed→ladder→writeback→seed) is self-contained in Core.
|
||||
// handle_all_collisions (PhysicsObjUpdate) then only READS body.FramesStationaryFall.
|
||||
body.FramesStationaryFall = ci.FramesStationaryFall;
|
||||
body.TransientState &= ~(TransientStateFlags.StationaryFall
|
||||
| TransientStateFlags.StationaryStop
|
||||
| TransientStateFlags.StationaryStuck);
|
||||
body.TransientState |= ci.FramesStationaryFall switch
|
||||
{
|
||||
1 => TransientStateFlags.StationaryFall,
|
||||
2 => TransientStateFlags.StationaryStop,
|
||||
3 => TransientStateFlags.StationaryStuck,
|
||||
_ => TransientStateFlags.None,
|
||||
};
|
||||
|
||||
if (sp.HasLastWalkablePolygon && sp.LastWalkableVertices is not null)
|
||||
{
|
||||
body.WalkablePolygonValid = true;
|
||||
body.WalkablePlane = sp.LastWalkablePlane;
|
||||
body.WalkableVertices = (Vector3[])sp.LastWalkableVertices.Clone();
|
||||
body.WalkableUp = sp.LastWalkableUp;
|
||||
}
|
||||
else if (!isOnGround && !ci.ContactPlaneValid && !ci.LastKnownContactPlaneValid)
|
||||
{
|
||||
body.WalkablePolygonValid = false;
|
||||
body.WalkableVertices = null;
|
||||
}
|
||||
|
||||
// Retail persists sliding state to the body ONLY on transition
|
||||
// SUCCESS: CPhysicsObj::SetPositionInternal copies the normal at
|
||||
// 0x005154c2 and syncs SLIDING_TS (bit 4) from the transition's
|
||||
// final sliding_normal_valid at 0x005154e1 — and SetPositionInternal
|
||||
// is unreachable when find_valid_position fails (the transition is
|
||||
// discarded whole; the body keeps its prior state). #137 mechanism
|
||||
// 2: an unconditional writeback here could persist a normal retail
|
||||
// would discard.
|
||||
if (ci.SlidingNormalValid
|
||||
&& ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
|
||||
{
|
||||
body.SlidingNormal = ci.SlidingNormal;
|
||||
body.TransientState |= TransientStateFlags.Sliding;
|
||||
}
|
||||
else
|
||||
{
|
||||
body.SlidingNormal = Vector3.Zero;
|
||||
body.TransientState &= ~TransientStateFlags.Sliding;
|
||||
}
|
||||
}
|
||||
|
||||
// L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity.
|
||||
// Phase 3's reset path sets VelocityKilled when an airborne hit
|
||||
// can't find a walkable surface (steep roof, wall) AND the
|
||||
// body had a last_known_contact_plane (i.e., was grounded
|
||||
// recently). Retail zeros all three velocity components so
|
||||
// gravity restarts cleanly next frame.
|
||||
//
|
||||
// Named-retail: OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0)
|
||||
// acclient_2013_pseudo_c.txt:274467-274475
|
||||
// Called from CTransition::transitional_insert reset path:
|
||||
// acclient_2013_pseudo_c.txt:273237 (Phase 3)
|
||||
// acclient_2013_pseudo_c.txt:272567 (validate_transition)
|
||||
if (transition.ObjectInfo.VelocityKilled)
|
||||
{
|
||||
if (PhysicsDiagnostics.DumpSteepRoofEnabled)
|
||||
Console.WriteLine($"[steep-roof] KILL-VELOCITY-APPLIED Vbefore=({body.Velocity.X:F2},{body.Velocity.Y:F2},{body.Velocity.Z:F2}) → 0,0,0");
|
||||
body.Velocity = Vector3.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
// L.3a (2026-04-30): surface the wall normal so callers can apply
|
||||
// retail's velocity-reflection bounce (CPhysicsObj::handle_all_collisions
|
||||
// at acclient_2013_pseudo_c.txt:282699-282715, ACE PhysicsObj.cs:
|
||||
// 2692-2697). The reflection itself is applied in
|
||||
// PlayerMovementController after the position commit, gated on
|
||||
// apply_bounce = !(prevOnWalkable && newOnWalkable) — airborne wall
|
||||
// hits bounce, grounded wall slides don't.
|
||||
bool collisionNormalValid = ci.CollisionNormalValid;
|
||||
Vector3 collisionNormal = ci.CollisionNormal;
|
||||
|
||||
// #42 diagnostic (2026-05-05): trace airborne sweeps to identify the
|
||||
// source of the ~1m XY drift on retail-observed stationary jumps.
|
||||
// Gated on ACDREAM_AIRBORNE_DIAG=1 and !isOnGround. One line per
|
||||
// resolve call. deltaXY = post - target tells us how much the sweep
|
||||
// diverged from the requested target; for a clean stationary +Z
|
||||
// jump we expect (0,0). cp=valid with a tilted normal would confirm
|
||||
// H1 (initial-overlap depenetration → next-step AdjustOffset projects
|
||||
// the +Z offset along a non-+Z normal). User repros at flat plaza /
|
||||
// east hillside / north hillside; if drift direction tracks terrain
|
||||
// orientation, H1 is the cause; if it tracks actor facing, H2 / H3.
|
||||
if (!isOnGround
|
||||
&& Environment.GetEnvironmentVariable("ACDREAM_AIRBORNE_DIAG") == "1")
|
||||
{
|
||||
var post = sp.CheckPos;
|
||||
float dx = post.X - targetPos.X;
|
||||
float dy = post.Y - targetPos.Y;
|
||||
string cpInfo = ci.ContactPlaneValid
|
||||
? $"valid cpN=({ci.ContactPlane.Normal.X:F3},{ci.ContactPlane.Normal.Y:F3},{ci.ContactPlane.Normal.Z:F3})"
|
||||
: "none";
|
||||
Console.WriteLine(
|
||||
$"[SWEEP] airborne pre=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) " +
|
||||
$"target=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) " +
|
||||
$"post=({post.X:F3},{post.Y:F3},{post.Z:F3}) " +
|
||||
$"cell={cellId:X8}->{sp.CheckCellId:X8} ok={ok} " +
|
||||
$"deltaXY=({dx:F3},{dy:F3}) cp={cpInfo}");
|
||||
}
|
||||
|
||||
// L.2a slice 1 (2026-05-12): general-purpose resolver probe.
|
||||
// One line per call when PhysicsDiagnostics.ProbeResolveEnabled
|
||||
// is set (env var ACDREAM_PROBE_RESOLVE=1 at startup, or the
|
||||
// DebugPanel checkbox flipped at runtime). Captures every
|
||||
// dimension L.2 cares about: input/output position, input/output
|
||||
// cell, ok-vs-partial, grounded-in vs contact-out, contact-plane
|
||||
// status, wall normal if hit, walkable polygon valid. Zero cost
|
||||
// when off (one static-bool read).
|
||||
if (PhysicsDiagnostics.ProbeResolveEnabled)
|
||||
{
|
||||
var probePost = sp.CheckPos;
|
||||
string probeCp = ci.ContactPlaneValid
|
||||
? "valid"
|
||||
: (ci.LastKnownContactPlaneValid ? "lastKnown" : "none");
|
||||
string probeHit;
|
||||
if (collisionNormalValid)
|
||||
{
|
||||
// L.2a slice 2 (2026-05-12): include the hit object's guid +
|
||||
// environment flag so we can tell whether the wall is a building
|
||||
// (CBuildingObj), a door (CC0Cxxxx range), an NPC, or terrain.
|
||||
// Without this we know the wall normal but not the responsible
|
||||
// entity — half the L.2d sub-direction call.
|
||||
string objPart = ci.LastCollidedObjectGuid.HasValue
|
||||
? System.FormattableString.Invariant(
|
||||
$" obj=0x{ci.LastCollidedObjectGuid.Value:X8}")
|
||||
: "";
|
||||
string envPart = ci.CollidedWithEnvironment ? " env" : "";
|
||||
int objCount = ci.CollideObjectGuids.Count;
|
||||
string objCountPart = objCount > 1
|
||||
? System.FormattableString.Invariant($" nObj={objCount}")
|
||||
: "";
|
||||
probeHit = System.FormattableString.Invariant(
|
||||
$"yes n=({collisionNormal.X:F2},{collisionNormal.Y:F2},{collisionNormal.Z:F2}){objPart}{envPart}{objCountPart}");
|
||||
resolveResult = new ResolveResult(
|
||||
sp.CheckPos,
|
||||
// Phase W Stage 1: return the transition's SWEPT cell (retail SetPositionInternal
|
||||
// reads sphere_path.curr_cell), not a static re-derive from the resting origin.
|
||||
// ValidateTransition advances sp.CurCellId only on accepted moves / reverts on
|
||||
// blocks, so push-back or standing still cannot flip it. The render root
|
||||
// (CellGraph.CurrCell) is NOT written here — this runs for EVERY entity; it is set
|
||||
// from this id only by the player's UpdateCellId (see UpdatePlayerCurrCell).
|
||||
sp.CurCellId,
|
||||
onGround,
|
||||
collisionNormalValid,
|
||||
collisionNormal,
|
||||
Orientation: sp.CurOrientation,
|
||||
InContact: inContact,
|
||||
OnWalkable: onWalkable);
|
||||
}
|
||||
else
|
||||
{
|
||||
probeHit = "no";
|
||||
// Transition failed (e.g., stuck in corner, too many steps).
|
||||
// Use whatever position the transition reached (partial movement)
|
||||
// instead of falling back to the no-collision Resolve.
|
||||
// If CheckPos hasn't moved from CurPos, the player stays put —
|
||||
// this is correct behavior when completely blocked.
|
||||
bool partialOnGround = ci.ContactPlaneValid
|
||||
|| (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0
|
||||
|| isOnGround;
|
||||
|
||||
uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
|
||||
resolveResult = new ResolveResult(
|
||||
sp.CheckPos,
|
||||
// Phase W Stage 1: prefer the swept cell; fall back to partialCellId only when
|
||||
// sp.CurCellId is zero (transition never advanced — teleport or physics reset).
|
||||
// (Render root set by the player's UpdateCellId, not here — see UpdatePlayerCurrCell.)
|
||||
sp.CurCellId != 0 ? sp.CurCellId : partialCellId,
|
||||
partialOnGround,
|
||||
collisionNormalValid,
|
||||
collisionNormal,
|
||||
Ok: false,
|
||||
Orientation: sp.CurOrientation); // Render Residual A — the sweep failed (find_valid_position == 0)
|
||||
}
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}"));
|
||||
}
|
||||
|
||||
// Phase W Stage 0 (2026-06-02): [cell-swept] probe — swept cell vs static-derived cell.
|
||||
// Emits before the ResolveResult is built so it shows what BOTH paths would return.
|
||||
// No ResolveCellId call here (it has a CellGraph.CurrCell side effect). No behavior change.
|
||||
if (PhysicsDiagnostics.ProbeSweptEnabled)
|
||||
// A6.P3 #98 capture: emit one JSON Lines record per player call,
|
||||
// with bodyBefore snapshot (taken at method entry, before any
|
||||
// engine mutation) + bodyAfter snapshot (taken now, after the
|
||||
// engine wrote back the contact plane / walkable / sliding state
|
||||
// to the body). Loaded by CellarUpTrajectoryReplayTests.cs.
|
||||
if (captureEnabled)
|
||||
{
|
||||
PhysicsResolveCapture.LogCall(
|
||||
new ResolveCallInputs(
|
||||
CurrentPos: currentPos,
|
||||
TargetPos: targetPos,
|
||||
CellId: cellId,
|
||||
SphereRadius: sphereRadius,
|
||||
SphereHeight: sphereHeight,
|
||||
StepUpHeight: stepUpHeight,
|
||||
StepDownHeight: stepDownHeight,
|
||||
IsOnGround: isOnGround,
|
||||
MoverFlags: (uint)moverFlags,
|
||||
MovingEntityId: movingEntityId),
|
||||
bodyBeforeSnap,
|
||||
new ResolveCallResult(
|
||||
Position: resolveResult.Position,
|
||||
CellId: resolveResult.CellId,
|
||||
IsOnGround: resolveResult.IsOnGround,
|
||||
CollisionNormalValid: resolveResult.CollisionNormalValid,
|
||||
CollisionNormal: resolveResult.CollisionNormal),
|
||||
body is not null ? PhysicsResolveCapture.Snapshot(body) : null);
|
||||
}
|
||||
|
||||
return resolveResult;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[cell-swept] ent=0x{movingEntityId:X8} ok={ok} inCell=0x{cellId:X8} curCell=0x{sp.CurCellId:X8} checkCell=0x{sp.CheckCellId:X8} curPos=({sp.CurPos.X:F3},{sp.CurPos.Y:F3},{sp.CurPos.Z:F3}) checkPos=({sp.CheckPos.X:F3},{sp.CheckPos.Y:F3},{sp.CheckPos.Z:F3})"));
|
||||
ReturnTransition(transition);
|
||||
}
|
||||
|
||||
ResolveResult resolveResult;
|
||||
if (ok)
|
||||
{
|
||||
bool inContact = ci.ContactPlaneValid;
|
||||
bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
|
||||
inContact,
|
||||
ci.ContactPlane.Normal);
|
||||
bool onGround = inContact
|
||||
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
|
||||
|
||||
resolveResult = new ResolveResult(
|
||||
sp.CheckPos,
|
||||
// Phase W Stage 1: return the transition's SWEPT cell (retail SetPositionInternal
|
||||
// reads sphere_path.curr_cell), not a static re-derive from the resting origin.
|
||||
// ValidateTransition advances sp.CurCellId only on accepted moves / reverts on
|
||||
// blocks, so push-back or standing still cannot flip it. The render root
|
||||
// (CellGraph.CurrCell) is NOT written here — this runs for EVERY entity; it is set
|
||||
// from this id only by the player's UpdateCellId (see UpdatePlayerCurrCell).
|
||||
sp.CurCellId,
|
||||
onGround,
|
||||
collisionNormalValid,
|
||||
collisionNormal,
|
||||
Orientation: sp.CurOrientation,
|
||||
InContact: inContact,
|
||||
OnWalkable: onWalkable);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Transition failed (e.g., stuck in corner, too many steps).
|
||||
// Use whatever position the transition reached (partial movement)
|
||||
// instead of falling back to the no-collision Resolve.
|
||||
// If CheckPos hasn't moved from CurPos, the player stays put —
|
||||
// this is correct behavior when completely blocked.
|
||||
bool partialOnGround = ci.ContactPlaneValid
|
||||
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable)
|
||||
|| isOnGround;
|
||||
|
||||
uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
|
||||
resolveResult = new ResolveResult(
|
||||
sp.CheckPos,
|
||||
// Phase W Stage 1: prefer the swept cell; fall back to partialCellId only when
|
||||
// sp.CurCellId is zero (transition never advanced — teleport or physics reset).
|
||||
// (Render root set by the player's UpdateCellId, not here — see UpdatePlayerCurrCell.)
|
||||
sp.CurCellId != 0 ? sp.CurCellId : partialCellId,
|
||||
partialOnGround,
|
||||
collisionNormalValid,
|
||||
collisionNormal,
|
||||
Ok: false,
|
||||
Orientation: sp.CurOrientation); // Render Residual A — the sweep failed (find_valid_position == 0)
|
||||
}
|
||||
|
||||
// A6.P3 #98 capture: emit one JSON Lines record per player call,
|
||||
// with bodyBefore snapshot (taken at method entry, before any
|
||||
// engine mutation) + bodyAfter snapshot (taken now, after the
|
||||
// engine wrote back the contact plane / walkable / sliding state
|
||||
// to the body). Loaded by CellarUpTrajectoryReplayTests.cs.
|
||||
if (captureEnabled)
|
||||
{
|
||||
PhysicsResolveCapture.LogCall(
|
||||
new ResolveCallInputs(
|
||||
CurrentPos: currentPos,
|
||||
TargetPos: targetPos,
|
||||
CellId: cellId,
|
||||
SphereRadius: sphereRadius,
|
||||
SphereHeight: sphereHeight,
|
||||
StepUpHeight: stepUpHeight,
|
||||
StepDownHeight: stepDownHeight,
|
||||
IsOnGround: isOnGround,
|
||||
MoverFlags: (uint)moverFlags,
|
||||
MovingEntityId: movingEntityId),
|
||||
bodyBeforeSnap,
|
||||
new ResolveCallResult(
|
||||
Position: resolveResult.Position,
|
||||
CellId: resolveResult.CellId,
|
||||
IsOnGround: resolveResult.IsOnGround,
|
||||
CollisionNormalValid: resolveResult.CollisionNormalValid,
|
||||
CollisionNormal: resolveResult.CollisionNormal),
|
||||
body is not null ? PhysicsResolveCapture.Snapshot(body) : null);
|
||||
}
|
||||
|
||||
return resolveResult;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1413,38 +1445,45 @@ public sealed class PhysicsEngine
|
|||
ObjectInfoState moverFlags = ObjectInfoState.None,
|
||||
uint movingEntityId = 0)
|
||||
{
|
||||
var transition = new Transition();
|
||||
transition.ObjectInfo.StepUpHeight = stepUpHeight;
|
||||
transition.ObjectInfo.StepDownHeight = stepDownHeight;
|
||||
transition.ObjectInfo.StepDown = true;
|
||||
transition.ObjectInfo.SelfEntityId = movingEntityId;
|
||||
transition.ObjectInfo.State = moverFlags;
|
||||
transition.SpherePath.InitPath(
|
||||
position, position, cellId, sphereRadius, sphereHeight);
|
||||
transition.SpherePath.InsertType = InsertType.Placement;
|
||||
var transition = RentTransition();
|
||||
try
|
||||
{
|
||||
transition.ObjectInfo.StepUpHeight = stepUpHeight;
|
||||
transition.ObjectInfo.StepDownHeight = stepDownHeight;
|
||||
transition.ObjectInfo.StepDown = true;
|
||||
transition.ObjectInfo.SelfEntityId = movingEntityId;
|
||||
transition.ObjectInfo.State = moverFlags;
|
||||
transition.SpherePath.InitPath(
|
||||
position, position, cellId, sphereRadius, sphereHeight);
|
||||
transition.SpherePath.InsertType = InsertType.Placement;
|
||||
|
||||
bool ok = transition.FindPlacementPos(this);
|
||||
var sp = transition.SpherePath;
|
||||
var ci = transition.CollisionInfo;
|
||||
bool inContact = ci.ContactPlaneValid;
|
||||
bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
|
||||
inContact,
|
||||
ci.ContactPlane.Normal);
|
||||
bool onGround = inContact
|
||||
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
|
||||
bool ok = transition.FindPlacementPos(this);
|
||||
var sp = transition.SpherePath;
|
||||
var ci = transition.CollisionInfo;
|
||||
bool inContact = ci.ContactPlaneValid;
|
||||
bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
|
||||
inContact,
|
||||
ci.ContactPlane.Normal);
|
||||
bool onGround = inContact
|
||||
|| (transition.ObjectInfo.State & ObjectInfoState.OnWalkable) != 0;
|
||||
|
||||
return new ResolveResult(
|
||||
sp.CurPos,
|
||||
sp.CurCellId != 0 ? sp.CurCellId : cellId,
|
||||
onGround,
|
||||
ci.CollisionNormalValid,
|
||||
ci.CollisionNormal,
|
||||
ok,
|
||||
Orientation: sp.CurOrientation,
|
||||
InContact: inContact,
|
||||
OnWalkable: onWalkable,
|
||||
ContactPlane: ci.ContactPlane,
|
||||
ContactPlaneCellId: ci.ContactPlaneCellId,
|
||||
ContactPlaneIsWater: ci.ContactPlaneIsWater);
|
||||
return new ResolveResult(
|
||||
sp.CurPos,
|
||||
sp.CurCellId != 0 ? sp.CurCellId : cellId,
|
||||
onGround,
|
||||
ci.CollisionNormalValid,
|
||||
ci.CollisionNormal,
|
||||
ok,
|
||||
Orientation: sp.CurOrientation,
|
||||
InContact: inContact,
|
||||
OnWalkable: onWalkable,
|
||||
ContactPlane: ci.ContactPlane,
|
||||
ContactPlaneCellId: ci.ContactPlaneCellId,
|
||||
ContactPlaneIsWater: ci.ContactPlaneIsWater);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReturnTransition(transition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue