acdream/src/AcDream.Core/Physics/PhysicsEngine.cs
Erik f8d669be88 fix(phys): A6.P3 slice 2 v2 — revert seed removal + add no-op guard
First slice 2 attempt at commit 892019b removed PhysicsEngine.cs L622
per-tick CP seed entirely. User happy-test surfaced a regression: BSP
step_up at the last step of stairs failed because sub-step 1's
AdjustOffset had no ContactPlane to compute the lift direction (the
seed was load-bearing for step_up correctness).

Revert + better fix:
  1. Re-add the L622 seed (PhysicsEngine.cs:620-626).
  2. Add no-op-if-unchanged guard inside CollisionInfo.SetContactPlane
     (TransitionTypes.cs:259-279). When called with values identical
     to current state, early-return without incrementing
     ContactPlaneWriteCount or rewriting fields.

When the player stands on the same plane tick after tick, the L622
seed re-calls SetContactPlane with identical args — these now no-op
instead of inflating the counter and re-writing the same values.
Only actual state changes (e.g. landing on a new step's plane, cell
crossing) increment the counter.

Verification (post-rebuild, pre-this-commit slice 2 first attempt):
- scen3 walk produced 2,690 cp-writes (down from 30,420 = 91%
  reduction from L622-seed presence)
- BUT user could not pass the last step of stairs — step_up regression
- Test suite: 1148 + 8 pre-existing fail baseline maintained but
  physical behavior broke

Post-this-commit expectations:
- Test suite: 1148 + 8 (unchanged, no behavioral change in fixtures
  because the seed value is what the fixtures already expect)
- Stair-walking: works (seed restored)
- CP-write count: significantly reduced (most seeds are no-ops because
  body.CP doesn't change tick-over-tick on stable footing)
- Issues #96 / #97: re-test in re-capture; #96 should be largely
  closed via the guard; #97 (fall-through + stuck-in-falling) was
  observed pre-slice-2 too, so unrelated to the seed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:32:23 +02:00

836 lines
38 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Physics;
internal readonly record struct TerrainWalkableSample(
System.Numerics.Plane Plane,
Vector3[] Vertices,
float WaterDepth,
bool IsWater,
uint CellId);
/// <summary>
/// Top-level physics resolver that combines <see cref="TerrainSurface"/> and
/// <see cref="CellSurface"/> to resolve entity movement with step-height
/// enforcement and outdoor/indoor cell transitions.
///
/// <para>
/// Landblocks are registered via <see cref="AddLandblock"/> with their
/// terrain, indoor cells, and world-space offsets. <see cref="Resolve"/>
/// takes a current position, the entity's current cell ID, a movement delta,
/// and a step-up height limit; it returns the validated new position, the
/// updated cell ID, and whether the entity is standing on a surface.
/// </para>
/// </summary>
public sealed class PhysicsEngine
{
private readonly Dictionary<uint, LandblockPhysics> _landblocks = new();
/// <summary>Number of registered landblocks (diagnostic).</summary>
public int LandblockCount => _landblocks.Count;
/// <summary>
/// Cell-based spatial index for static object collision.
/// Populated during landblock streaming; queried by the Transition system.
/// </summary>
public ShadowObjectRegistry ShadowObjects { get; } = new();
/// <summary>
/// Physics BSP cache shared with the streaming loader. Set once by the
/// host (GameWindow) immediately after construction. The Transition system
/// reads this during FindObjCollisions to perform narrow-phase BSP tests.
/// </summary>
public PhysicsDataCache? DataCache { get; set; }
private sealed record LandblockPhysics(
TerrainSurface Terrain,
IReadOnlyList<CellSurface> Cells,
IReadOnlyList<PortalPlane> Portals,
float WorldOffsetX,
float WorldOffsetY);
/// <summary>
/// Register a landblock with its terrain surface, indoor cells, portal
/// planes, and world-space origin offset.
/// </summary>
public void AddLandblock(uint landblockId, TerrainSurface terrain,
IReadOnlyList<CellSurface> cells, IReadOnlyList<PortalPlane> portals,
float worldOffsetX, float worldOffsetY)
{
_landblocks[landblockId] = new LandblockPhysics(terrain, cells, portals, worldOffsetX, worldOffsetY);
}
/// <summary>
/// Remove a previously registered landblock, including its shadow objects.
/// </summary>
public void RemoveLandblock(uint landblockId)
{
_landblocks.Remove(landblockId);
ShadowObjects.RemoveLandblock(landblockId);
}
/// <summary>
/// Find the landblock that contains the given world-space XY position and
/// return its ID plus world-space origin offsets. Returns false when no
/// registered landblock covers the position.
/// Used by Transition.FindObjCollisions to build the shadow-object query.
/// </summary>
public bool TryGetLandblockContext(float worldX, float worldY,
out uint landblockId, out float worldOffsetX, out float worldOffsetY)
{
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
float localX = worldX - lb.WorldOffsetX;
float localY = worldY - lb.WorldOffsetY;
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
{
landblockId = kvp.Key;
worldOffsetX = lb.WorldOffsetX;
worldOffsetY = lb.WorldOffsetY;
return true;
}
}
landblockId = 0;
worldOffsetX = 0f;
worldOffsetY = 0f;
return false;
}
/// <summary>
/// Sample the outdoor terrain Z at the given world-space XY position.
/// Searches all registered landblocks; returns null if no landblock covers the position.
/// Used by Transition.FindEnvCollisions for terrain collision resolution.
/// </summary>
public float? SampleTerrainZ(float worldX, float worldY)
{
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
float localX = worldX - lb.WorldOffsetX;
float localY = worldY - lb.WorldOffsetY;
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
return lb.Terrain.SampleZ(localX, localY);
}
return null;
}
/// <summary>
/// Sample the per-point water depth at the given world-space XY
/// (meters by which the character is allowed to sink below the
/// contact plane — 0.9 on fully-flooded water cells, 0.45 on
/// partial-water near a water corner, 0.1 on non-water corners of
/// partial-water cells, 0 on dry cells). Matches ACE
/// <c>ObjCell.get_water_depth</c>. Used by
/// <see cref="Transition"/> to visually submerge characters in water
/// without needing a separate water surface mesh.
/// </summary>
public float SampleWaterDepth(float worldX, float worldY)
{
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
float localX = worldX - lb.WorldOffsetX;
float localY = worldY - lb.WorldOffsetY;
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
return lb.Terrain.SampleWaterDepth(localX, localY);
}
return 0f;
}
/// <summary>
/// Sample the outdoor terrain plane (Z + sloped normal) at the given
/// world-space XY position. The returned <see cref="System.Numerics.Plane"/>
/// has the true terrain-triangle normal (NOT a flat <c>(0,0,1)</c>), and
/// its <c>D</c> is set so the plane passes through the sampled point. Used
/// by <see cref="Transition"/> to build a CORRECT contact plane — a flat
/// plane breaks slope tracking because <c>AdjustOffset</c>'s projection
/// onto a flat plane cannot impart the Z component that horizontal
/// velocity needs to follow the slope.
/// </summary>
public System.Numerics.Plane? SampleTerrainPlane(float worldX, float worldY)
{
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
float localX = worldX - lb.WorldOffsetX;
float localY = worldY - lb.WorldOffsetY;
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
{
var (z, normal) = lb.Terrain.SampleSurface(localX, localY);
// System.Numerics.Plane convention: dot(Normal, P) + D == 0
// for points P on the plane. Pick P = (worldX, worldY, z).
float d = -(normal.X * worldX + normal.Y * worldY + normal.Z * z);
return new System.Numerics.Plane(normal, d);
}
}
return null;
}
/// <summary>
/// Public surface for callers that only need the local terrain plane
/// normal at a world-space XY (e.g., the grounded-remote tick path
/// projecting anim root motion onto the slope to avoid the staircase
/// between server position updates). Returns null when no registered
/// landblock covers the point. Mirrors the plane component of
/// <see cref="SampleTerrainWalkable"/> without exposing the internal
/// <c>TerrainWalkableSample</c> shape.
/// </summary>
public Vector3? SampleTerrainNormal(float worldX, float worldY)
{
var sample = SampleTerrainWalkable(worldX, worldY);
return sample?.Plane.Normal;
}
/// <summary>
/// Sample the outdoor terrain walkable triangle at the given world-space
/// XY position. This carries the same plane as <see cref="SampleTerrainPlane"/>
/// plus world-space triangle vertices for retail precipice-slide.
/// </summary>
internal TerrainWalkableSample? SampleTerrainWalkable(float worldX, float worldY)
{
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
float localX = worldX - lb.WorldOffsetX;
float localY = worldY - lb.WorldOffsetY;
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 normal = sample.Normal;
float d = -Vector3.Dot(normal, vertices[0]);
var plane = new System.Numerics.Plane(normal, d);
float waterDepth = lb.Terrain.SampleWaterDepth(localX, localY);
bool isWater = waterDepth >= 0.45f;
uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
uint fullCellId = (kvp.Key & 0xFFFF0000u) | lowCellId;
return new TerrainWalkableSample(
plane,
vertices,
waterDepth,
isWater,
fullCellId);
}
}
return null;
}
/// <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
/// cells, or via terrain grid lookup for outdoor cells.
///
/// <para>
/// Indoor seed: delegates to <see cref="CellTransit.FindCellList"/> which
/// BFS-walks the portal graph and uses <see cref="BSPQuery.PointInsideCellBsp"/>
/// for containment. This replaces Phase D's AABB shortcut.
/// </para>
///
/// <para>
/// Outdoor seed: uses the registered landblock terrain grid to compute
/// the correct prefixed cell ID, preserving the pre-existing outdoor
/// resolution behavior (the L.2e prefix-preservation fix).
/// </para>
///
/// <para>
/// Design: <c>docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md</c>
/// </para>
/// </summary>
internal uint ResolveCellId(Vector3 worldPos, float sphereRadius, uint fallbackCellId)
{
if (fallbackCellId == 0) return 0;
uint fallbackLow = fallbackCellId & 0xFFFFu;
if (fallbackLow >= 0x0100u)
{
// Indoor branch needs DataCache to look up cells; outdoor uses
// _landblocks (no DataCache dependency).
if (DataCache is null) return fallbackCellId;
uint indoorResult = CellTransit.FindCellList(DataCache, worldPos, sphereRadius, fallbackCellId);
// ISSUES #83 / Phase A1.7 (2026-05-21): verify the indoor result
// actually contains the player. CellTransit.FindCellList falls back
// to currentCellId when no candidate cell's CellBSP contains the
// sphere center — but this happens even when the player has walked
// OUTSIDE the entire portal-connected indoor cell graph (e.g.,
// exited through an unblocked wall or doorway gap). In that state
// the player's CellId is stuck on an indoor cell whose BSP is
// far away, every indoor-bsp query returns OK (NodeIntersects
// fails at root), and no walls block.
//
// If the resolved indoor cell's BSP does NOT contain the sphere
// center, fall through to the outdoor cell resolution below — it
// will compute the correct landcell from the terrain grid and
// optionally re-enter an indoor cell via CheckBuildingTransit.
var indoorCell = DataCache.GetCellStruct(indoorResult);
if (indoorCell?.CellBSP?.Root is null)
return indoorResult; // Can't verify (no CellBSP); trust FindCellList.
// Issue #90 fix (2026-05-20): use SPHERE-overlap instead of POINT-in
// for the indoor verification. The previous point-only check caused
// a per-frame ping-pong at the inn doorway: indoor BSP push-back
// moved the sphere CENTER a few cm outside the indoor CellBSP
// volume → point-only check returned false → fell through to outdoor
// → next tick re-promoted to indoor → wall hit → push-back →
// outdoor → repeat. Net visual behavior: "walls walk through"
// because outdoor ticks bypass indoor BSP entirely. With sphere-
// overlap, the player stays classified indoor as long as ANY part
// of the foot sphere still overlaps the indoor cell volume.
//
// Retail oracle: CCellStruct::sphere_intersects_cell at
// acclient_2013_pseudo_c.txt:317666 →
// BSPTREE::sphere_intersects_cell_bsp at :323267.
var localCenter = Vector3.Transform(worldPos, indoorCell.InverseWorldTransform);
if (BSPQuery.SphereIntersectsCellBsp(indoorCell.CellBSP.Root, localCenter, sphereRadius))
return indoorResult;
// Fall through to outdoor resolution: player has FULLY left the
// indoor portal-connected graph (sphere no longer overlaps).
}
// Outdoor seed: use terrain grid to compute the prefixed cell id.
// Preserves the L.2e prefix-preservation fix (always apply the matched
// landblock's high-16 prefix even when fallbackCellId arrived bare-low-byte).
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
float localX = worldPos.X - lb.WorldOffsetX;
float localY = worldPos.Y - lb.WorldOffsetY;
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
{
uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
uint outdoorCellId = (kvp.Key & 0xFFFF0000u) | lowCellId;
// Outdoor→indoor entry: if this landcell has a cached building,
// check whether the sphere has crossed into one of its interior
// EnvCells via the building's portals.
if (DataCache is not null)
{
var building = DataCache.GetBuilding(outdoorCellId);
if (building is not null)
{
var candidates = new System.Collections.Generic.HashSet<uint>();
CellTransit.CheckBuildingTransit(
DataCache, building, worldPos, sphereRadius, candidates);
if (candidates.Count > 0)
{
// First candidate wins — building portal containment is
// mutually exclusive in retail (one interior cell per portal).
foreach (var c in candidates) return c;
}
}
}
return outdoorCellId;
}
}
return fallbackCellId;
}
/// <summary>
/// Resolve an entity's movement from <paramref name="currentPos"/> by
/// applying <paramref name="delta"/> (XY only) and computing the correct Z
/// from the terrain or indoor cell floor beneath the candidate position.
///
/// <para>
/// Step-height enforcement rejects horizontal movement when the upward Z
/// change exceeds <paramref name="stepUpHeight"/>. Downhill movement is
/// always accepted. Returns <see cref="ResolveResult.IsOnGround"/> false
/// when no loaded landblock covers the candidate position.
/// </para>
/// </summary>
public ResolveResult Resolve(Vector3 currentPos, uint cellId, Vector3 delta, float stepUpHeight)
{
var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f);
// Find the landblock this candidate position falls in.
LandblockPhysics? physics = null;
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
float localX = candidatePos.X - lb.WorldOffsetX;
float localY = candidatePos.Y - lb.WorldOffsetY;
if (localX >= 0 && localX < 192f && localY >= 0 && localY < 192f)
{
physics = lb;
break;
}
}
if (physics is null)
return new ResolveResult(candidatePos, cellId, IsOnGround: false);
float localCandX = candidatePos.X - physics.WorldOffsetX;
float localCandY = candidatePos.Y - physics.WorldOffsetY;
// Check if the candidate position falls on any indoor cell floor.
// Pick the cell whose floor Z is closest to the entity's current Z.
CellSurface? bestCell = null;
float? bestCellZ = null;
float bestZDist = float.MaxValue;
foreach (var cell in physics.Cells)
{
float? floorZ = cell.SampleFloorZ(candidatePos.X, candidatePos.Y);
if (floorZ is not null)
{
float dist = MathF.Abs(floorZ.Value - currentPos.Z);
if (dist < bestZDist)
{
bestCell = cell;
bestCellZ = floorZ;
bestZDist = dist;
}
}
}
// Determine target surface Z and cell.
float terrainZ = physics.Terrain.SampleZ(localCandX, localCandY);
float targetZ;
uint targetCellId;
// Only the low 16 bits of cellId carry the cell index. Outdoor
// cells are 0x00010x0040; indoor (EnvCell) cells are 0x0100+.
// The full 32-bit cellId includes the landblock prefix in the
// high 16 bits (e.g., 0xA9B40001), so we MUST mask before
// comparing. Without the mask, every cell looks "indoor" because
// 0xA9B40001 >= 0x0100 → the engine always takes the "stay
// indoors" path and snaps Z to an EnvCell floor 28m below.
bool currentlyIndoor = (cellId & 0xFFFFu) >= 0x0100;
if (currentlyIndoor)
{
// Check whether the player crosses a portal belonging to the current cell.
uint currentCellIndex = cellId & 0xFFFFu;
PortalPlane? crossedPortal = null;
foreach (var portal in physics.Portals)
{
// Only portals owned by the current cell are relevant when indoors.
if ((portal.OwnerCellId & 0xFFFFu) != currentCellIndex) continue;
if (portal.IsCrossing(currentPos, candidatePos))
{
crossedPortal = portal;
break;
}
}
if (crossedPortal is not null)
{
if (crossedPortal.Value.TargetCellId == 0xFFFFu)
{
// Indoor → Outdoor exit.
targetZ = terrainZ;
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
}
else
{
// Indoor → Indoor (room to room).
uint nextCellIndex = crossedPortal.Value.TargetCellId & 0xFFFFu;
CellSurface? nextCell = null;
foreach (var c in physics.Cells)
{
if ((c.CellId & 0xFFFFu) == nextCellIndex) { nextCell = c; break; }
}
float? nextFloorZ = nextCell?.SampleFloorZ(candidatePos.X, candidatePos.Y);
targetZ = nextFloorZ ?? terrainZ;
targetCellId = nextCellIndex;
}
}
else if (bestCellZ is not null)
{
// Staying in the same indoor cell.
targetZ = bestCellZ.Value;
targetCellId = bestCell!.CellId & 0xFFFFu;
}
else
{
// No cell floor found and no portal crossed — fall back to outdoor.
targetZ = terrainZ;
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
}
}
else
{
// Outdoor player: check for a portal crossing into an indoor cell.
// Outside-facing portals have TargetCellId == 0xFFFF (they face the
// outdoor world); crossing one from the outdoor side enters the OwnerCellId.
PortalPlane? crossedPortal = null;
foreach (var portal in physics.Portals)
{
if (portal.TargetCellId != 0xFFFFu) continue; // only outside-facing portals
if (portal.IsCrossing(currentPos, candidatePos))
{
crossedPortal = portal;
break;
}
}
if (crossedPortal is not null)
{
// Outdoor → Indoor: enter the OwnerCellId IF the target cell
// actually contains the candidate position. Without CellBSP,
// we verify by checking that SampleFloorZ returns non-null
// (position is within the cell's floor polygon bounds) AND the
// floor Z is close to the player's current Z (not a basement
// 30m below). This prevents the wall-bounce bug where portal
// planes on upper floors captured outdoor positions.
uint enterCellIndex = crossedPortal.Value.OwnerCellId & 0xFFFFu;
CellSurface? enterCell = null;
foreach (var c in physics.Cells)
{
if ((c.CellId & 0xFFFFu) == enterCellIndex) { enterCell = c; break; }
}
float? enterFloorZ = enterCell?.SampleFloorZ(candidatePos.X, candidatePos.Y);
// Validate: floor must exist AND be within step height of current Z.
// This rejects transitions to basements, upper floors, and cells
// whose floor polygon doesn't actually cover this position.
bool validTransition = enterFloorZ is not null
&& MathF.Abs(enterFloorZ.Value - currentPos.Z) < stepUpHeight + 2f;
if (validTransition)
{
targetZ = enterFloorZ!.Value;
targetCellId = enterCellIndex;
}
else
{
// Portal crossed but target cell doesn't contain us — stay outdoor.
targetZ = terrainZ;
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
}
}
else
{
// Stay outdoors on terrain.
targetZ = terrainZ;
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
}
}
// Step-height enforcement: block upward movement that exceeds the limit.
float zDelta = targetZ - currentPos.Z;
if (zDelta > stepUpHeight)
{
// Too steep to step up — reject horizontal movement.
return new ResolveResult(currentPos, cellId, IsOnGround: true);
}
return new ResolveResult(
new Vector3(candidatePos.X, candidatePos.Y, targetZ),
targetCellId,
IsOnGround: true);
}
/// <summary>
/// Resolve movement using the CTransition sphere-sweep system.
/// Subdivides movement into sphere-radius steps, tests terrain collision
/// at each step, handles step-down for ground contact.
/// Falls back to the simple <see cref="Resolve"/> if the transition fails.
///
/// <para>
/// <paramref name="body"/> is optional but highly recommended for movement
/// that runs across multiple frames. When provided, the previous frame's
/// contact plane is copied INTO the transition's CollisionInfo (mirroring
/// retail's <c>PhysicsObj.get_object_info → InitContactPlane</c> at
/// <c>PhysicsObj.cs:2598-2604</c>). That seed is critical for slope
/// tracking: <c>AdjustOffset</c> projects the Euler offset onto the plane
/// so horizontal velocity acquires the correct Z component for the slope,
/// preventing the character from floating on downhill runs where the
/// per-frame descent exceeds the 4 cm step-down budget.
/// </para>
///
/// <para>
/// On return, the plane discovered during this call is written BACK to
/// <paramref name="body"/>, so the next frame's transition starts with
/// an up-to-date plane seed. Callers without a persistent body (tests,
/// one-shot movements) can pass <c>null</c> and accept the first-frame
/// hiccup.
/// </para>
/// </summary>
public ResolveResult ResolveWithTransition(
Vector3 currentPos, Vector3 targetPos, uint cellId,
float sphereRadius, float sphereHeight,
float stepUpHeight, float stepDownHeight,
bool isOnGround,
PhysicsBody? body = null,
ObjectInfoState moverFlags = ObjectInfoState.None,
uint movingEntityId = 0)
{
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;
// 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;
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)
{
transition.CollisionInfo.SetContactPlane(
body.ContactPlane,
body.ContactPlaneCellId,
body.ContactPlaneIsWater);
}
// 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);
}
transition.SpherePath.InitPath(currentPos, targetPos, cellId, sphereRadius, sphereHeight);
if (isOnGround && body is not null
&& body.WalkablePolygonValid
&& body.WalkableVertices is { Length: >= 3 })
{
transition.SpherePath.SetWalkable(
body.WalkablePlane,
body.WalkableVertices,
body.WalkableUp);
}
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)
{
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;
}
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;
}
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}"));
}
if (ok)
{
bool onGround = ci.ContactPlaneValid
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
return new ResolveResult(
sp.CheckPos,
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId),
onGround,
collisionNormalValid,
collisionNormal);
}
// 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;
return new ResolveResult(
sp.CheckPos,
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, partialCellId),
partialOnGround,
collisionNormalValid,
collisionNormal);
}
}