acdream/src/AcDream.Core/Physics/PhysicsEngine.cs
Erik fb5fba6229 test(phys): A6.P3 #98 — live ResolveWithTransition capture apparatus
Apparatus only — no fix attempt. Per the systematic-debugging skill's
"3+ failures = question architecture" rule, the 6 hypotheses we
tested speculatively on the harness's airborne-at-tick-1 bug all
failed because we kept guessing what state the harness lacks. This
commit ships the evidence-driven path: capture the EXACT player
ResolveWithTransition call (every input + body-before + body-after +
result) into a JSON Lines fixture, then a comparison test loads the
fixture and replays it against the test engine. The first per-field
divergence pinpoints the missing apparatus state — no more guessing.

Adds:
- src/AcDream.Core/Physics/PhysicsResolveCapture.cs — new static module
  with CapturePath (env var ACDREAM_CAPTURE_RESOLVE), PhysicsBodySnapshot
  record, JSON Lines writer (thread-safe, flushes per record), process-
  exit hook for clean shutdown.

- PhysicsEngine.ResolveWithTransition probe wiring: snapshot body at
  method entry, snapshot again before return, refactor the two returns
  into one path so the capture call site is single. Filtered to
  IsPlayer mover flag so NPC/remote DR calls don't pollute.

- CellarUpTrajectoryReplayTests.cs:
  • Capture_WritesJsonLinesRecordsWhenIsPlayerAndEnabled — drives 3
    ticks with capture on, reads file back, verifies round-trip of
    inputs + body-before/after snapshots.
  • Capture_SkipsNonPlayerCalls — drives 3 NPC-style ticks (no
    IsPlayer flag), confirms the file is not created.

Off by default. Set ACDREAM_CAPTURE_RESOLVE=<path> to a writable file
path; capture starts on the next player ResolveWithTransition call.

Test baseline: 1172 + 8 pre-existing failures + 2 new smoke tests
that pass = 1174 + 8. Verified by stashed-baseline comparison.

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

903 lines
41 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;
// ── Cell-stickiness REVERTED (A6.P3 slice 3 v3, 2026-05-22) ──
// Slice 3 v1 (sphere-overlap, 8898166) over-corrected — held
// player in cellar even when transitioning out at the ramp top.
// Slice 3 v2 (point-in, 3e140cf) closed the ping-pong at the
// inn doorway (data confirmed) BUT prevented the player from
// reaching the top of the cellar ramp (the stuck spot
// transitioned from "ping-pong at top" to "never reach top").
//
// Reverting to no-stickiness for now. The ping-pong at the inn
// doorway returns but is a lesser evil than blocking cellar-up
// entirely. Issue #98 cellar-up has a deeper bug that needs
// separate investigation (BSP step-physics or AdjustOffset
// slope-projection at the cottage main floor boundary).
//
// Slice 3 work remains valuable as research evidence; the fix
// shape was wrong. Issue #90 stays as workaround until a
// better stickiness mechanism is designed (probably needs to
// be GATED by some "near cell boundary" check rather than
// applied unconditionally).
// Fallback cell no longer valid → re-resolve via portal-graph BFS.
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)
{
// A6.P3 #98 (2026-05-23) live capture. Filtered to IsPlayer so NPC /
// remote ResolveWithTransition calls don't pollute the capture. Snapshot
// 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);
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;
// 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}"));
}
ResolveResult resolveResult;
if (ok)
{
bool onGround = ci.ContactPlaneValid
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
resolveResult = new ResolveResult(
sp.CheckPos,
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId),
onGround,
collisionNormalValid,
collisionNormal);
}
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,
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, partialCellId),
partialOnGround,
collisionNormalValid,
collisionNormal);
}
// 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;
}
}