Slice 3 v2 (point-in stickiness) closed the cell-resolver ping-pong (data confirmed: scen4_cottage_cellar_slice3v2 capture shows 1 cell- transit vs 20+ pre-fix). BUT user verification revealed: cellar-up symptom transitioned from "stuck-at-top-ping-pong" (pre-slice-3) to "never-reach-top-stuck-in-cellar" (post-slice-3). Stickiness was holding player in cellar cell so aggressively that the legitimate transition to the cottage main floor cell at the ramp top never fired. Reverting the stickiness check entirely. Trade-off: - Inn doorway ping-pong returns (existed pre-slice-3; lesser evil) - Player can again reach the top of the cellar ramp (per pre-slice-3 user observation) - Issue #98 cellar-up remains open — but with sharper diagnosis: it's not the cell resolver at all, it's deeper (BSP step-physics or AdjustOffset slope-projection at the cottage main floor boundary, per slice 4 polydump trace showing repeated push-back on the 46-degree ramp polygon) The slice 3 stickiness premise was correct but the implementation shape was wrong. A future attempt needs either: - A "near boundary" gate (only stick when sphere is deep inside cell) - A retail-faithful per-cell hysteresis matching CObjCell::find_cell_list Position-variant (acclient_2013_pseudo_c.txt:308742-308783) more exactly than point-in - OR address the underlying BSP step-physics bug first; then ping-pong may not even need a stickiness fix Test suite: 1148 + 8 (baseline maintained). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
858 lines
39 KiB
C#
858 lines
39 KiB
C#
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 0x0001–0x0040; 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);
|
||
}
|
||
}
|