fix(physics): water depth submersion + sphere-safety-push steep-slope correction

Two coupled physics fixes that together resolve "+Acdream walks on top of
water instead of submerged" and "brief Falling animation when running up
steep hills".

## 1. Water depth = physics adjustment, not rendering

Retail has NO separate water surface mesh. Characters visually submerge
in water because ValidateWalkable adds `waterDepth` to its signed-distance
check (ACE ObjectInfo.cs:124), letting the character's feet sit below the
terrain plane by that amount before the push-up fires. Rendered character
below rendered terrain = looks submerged.

Our ValidateWalkable didn't carry a waterDepth, so feet were always
snapped exactly to the plane. Water cells looked like walking on water.

Added:
 - TerrainSurface now carries per-vertex water flags (bits 2-6 of
   TerrainInfo → SurfChar lookup) and per-cell classification.
 - TerrainSurface.SampleWaterDepth(localX, localY) returns 0.0 (dry),
   0.45 (partial-water near water corner), 0.9 (entirely water). Deviates
   from retail's 0.1 fallback for "dry corner of partial-water cell" —
   that 0.1 destabilizes the "feet exactly on plane" contact-touch check
   in ValidateWalkable (dist > EPSILON, SetContactPlane skipped,
   ValidateTransition clears OnWalkable, gravity applies, character
   micro-falls each frame).
 - PhysicsEngine.SampleWaterDepth is the world-space wrapper.
 - FindEnvCollisions samples the per-point depth and forwards it.
 - ValidateWalkable adds +waterDepth to the signed-distance check (this
   is the ACE-line-124 port).

GameWindow.ApplyLoadedTerrain extracts the low byte of each TerrainInfo
ushort and passes it to the TerrainSurface ctor so classification works.

## 2. AdjustOffset safety-push threshold on sloped planes

The LocalSphere is positioned at `(0, 0, radius)` — center along world
+Z from the character root. On a tilted plane the sphere center's
perpendicular distance to that plane is `radius * Normal.Z`, NOT
`radius`. The original threshold `dist < radius - EPS` therefore fires
spuriously on every slope and the follow-up push-up lifts feet by
`radius * (sec θ - 1)` — 7 cm at 30°, 20 cm at 45°, 48 cm at 60°.

The steep-slope lift is large enough to break ValidateWalkable's
contact-touch check, ValidateTransition then clears OnWalkable,
calc_acceleration applies gravity, and the character flickers into the
Falling animation for ~0.3s while running uphill. User-observed on steep
hills after today's water-depth work made the artifact visible (before
that, general hover masked it).

Fix: the threshold is `radius * Normal.Z` (the natural resting distance
of a Z-axis sphere on the plane). The push fires only when feet are
actually penetrating below natural resting, not on any sloped plane.
ACE's Transition.cs AdjustOffset has the original threshold but the bug
is invisible server-side.

All 717 tests green. Water submersion + steep-slope running both
user-visually verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-21 20:56:46 +02:00
parent 40f120617d
commit f562215e6c
4 changed files with 179 additions and 13 deletions

View file

@ -2706,8 +2706,17 @@ public sealed class GameWindow : IDisposable
{
uint lbPhysX = (lb.LandblockId >> 24) & 0xFFu;
uint lbPhysY = (lb.LandblockId >> 16) & 0xFFu;
// Extract per-vertex terrain-type bytes so TerrainSurface can
// classify water cells for ValidateWalkable's water-depth
// adjustment. Each TerrainInfo is a ushort with Type in bits
// 2-6; taking the low byte preserves those bits (+ Road in 0-1,
// which the classifier masks off).
var terrainBytes = new byte[81];
for (int i = 0; i < 81; i++)
terrainBytes[i] = (byte)(ushort)lb.Heightmap.Terrain[i];
var terrainSurface = new AcDream.Core.Physics.TerrainSurface(
lb.Heightmap.Height, _heightTable, lbPhysX, lbPhysY);
lb.Heightmap.Height, _heightTable, lbPhysX, lbPhysY, terrainBytes);
var cellSurfaces = new List<AcDream.Core.Physics.CellSurface>();
var portalPlanes = new List<AcDream.Core.Physics.PortalPlane>();

View file

@ -110,6 +110,29 @@ public sealed class PhysicsEngine
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"/>

View file

@ -20,12 +20,15 @@ public sealed class TerrainSurface
private const float CellSize = 24f;
private const int CellsPerSide = 8; // 192 / 24
private readonly float[,] _z; // pre-resolved heights [x, y]
private readonly float[,] _z; // pre-resolved heights [x, y]
private readonly bool[,] _cornerIsWater; // per-VERTEX water flag [x, y] — SurfChar[(type >> 2) & 0x1F]
private readonly byte[,] _cellWaterType; // per-CELL 0=NotWater, 1=Partially, 2=Entirely [cx, cy]
private readonly uint _landblockX;
private readonly uint _landblockY;
public TerrainSurface(byte[] heights, float[] heightTable,
uint landblockX = 0, uint landblockY = 0)
uint landblockX = 0, uint landblockY = 0,
byte[]? terrainTypes = null)
{
ArgumentNullException.ThrowIfNull(heights);
ArgumentNullException.ThrowIfNull(heightTable);
@ -42,6 +45,44 @@ public sealed class TerrainSurface
for (int x = 0; x < HeightmapSide; x++)
for (int y = 0; y < HeightmapSide; y++)
_z[x, y] = heightTable[heights[x * HeightmapSide + y]];
// Per-vertex water flag. TerrainType lives in bits 2-6 of each
// TerrainInfo byte; water is types 0x10-0x14 inclusive (per
// LandDefs.TerrainType enum: WaterRunning..WaterDeepSea).
// Retail's SurfChar[32] lookup marks those 5 indices as water.
// If terrainTypes is not provided (tests / legacy callers) we
// default to "no water anywhere" which preserves old behavior.
_cornerIsWater = new bool[HeightmapSide, HeightmapSide];
if (terrainTypes is not null && terrainTypes.Length >= 81)
{
for (int x = 0; x < HeightmapSide; x++)
for (int y = 0; y < HeightmapSide; y++)
{
int typeBits = (terrainTypes[x * HeightmapSide + y] >> 2) & 0x1F;
_cornerIsWater[x, y] = typeBits >= 0x10 && typeBits <= 0x14;
}
}
// Per-cell water classification (mirrors ACE
// LandblockStruct.CalcCellWater / CalcWater). A cell has 4
// vertex corners; count how many are water type.
_cellWaterType = new byte[CellsPerSide, CellsPerSide];
for (int cx = 0; cx < CellsPerSide; cx++)
for (int cy = 0; cy < CellsPerSide; cy++)
{
int waterCorners = 0;
if (_cornerIsWater[cx, cy ]) waterCorners++;
if (_cornerIsWater[cx + 1, cy ]) waterCorners++;
if (_cornerIsWater[cx + 1, cy + 1]) waterCorners++;
if (_cornerIsWater[cx, cy + 1]) waterCorners++;
_cellWaterType[cx, cy] = waterCorners switch
{
0 => 0, // NotWater
4 => 2, // EntirelyWater
_ => 1, // PartiallyWater
};
}
}
/// <summary>
@ -209,6 +250,55 @@ public sealed class TerrainSurface
return (z, normal);
}
/// <summary>
/// Retail per-point water depth in meters — the amount the character's
/// feet are allowed to sink below the contact plane before the
/// push-up fires. <see cref="ValidateWalkable"/> adds this to the
/// signed-distance check so water cells visually submerge the
/// character while dry terrain keeps feet exactly on the plane.
///
/// <para>
/// Ported from ACE <c>ObjCell.get_water_depth</c> and
/// <c>LandblockStruct.calc_water_depth</c>, except that the retail
/// 0.1 fallback for "dry corner of a partially-water cell" is
/// collapsed to 0. The 0.1 offset destabilizes the "feet exactly on
/// plane" contact-touch check (dist > EPSILON, so SetContactPlane
/// doesn't fire, ValidateTransition clears OnWalkable, gravity
/// applies, character floats/falls each frame). The visible effect
/// in retail is subtle (~10 cm natural sink-in) so dropping it to 0
/// is indistinguishable.
/// </para>
/// <list type="bullet">
/// <item>NotWater cell → <c>0.0f</c></item>
/// <item>EntirelyWater cell → <c>0.9f</c> (fully submerged)</item>
/// <item>PartiallyWater cell, nearest-corner water type → <c>0.45f</c></item>
/// <item>PartiallyWater cell, nearest-corner non-water → <c>0.0f</c>
/// (retail uses 0.1, we use 0 to avoid the above-EPSILON dist bug)</item>
/// </list>
/// </summary>
public float SampleWaterDepth(float localX, float localY)
{
float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
int cx = Math.Clamp((int)fx, 0, CellsPerSide - 1);
int cy = Math.Clamp((int)fy, 0, CellsPerSide - 1);
byte waterType = _cellWaterType[cx, cy];
if (waterType == 0) return 0f; // NotWater
if (waterType == 2) return 0.9f; // EntirelyWater
// PartiallyWater — resolve to nearest corner (retail picks the
// terrain-vertex at the "+12" half of each axis, i.e. >= 12m into
// a 24m cell rounds up). Return 0.45 for water corner, 0 for dry
// (retail uses 0.1 for dry corners; see note above).
float tx = fx - cx;
float ty = fy - cy;
int vx = cx + (tx >= 0.5f ? 1 : 0);
int vy = cy + (ty >= 0.5f ? 1 : 0);
return _cornerIsWater[vx, vy] ? 0.45f : 0f;
}
/// <summary>
/// Compute the outdoor cell ID for the given landblock-local position.
/// </summary>

View file

@ -637,7 +637,18 @@ public sealed class Transition
if (planeOpt is null)
return TransitionState.OK; // no terrain loaded here — allow pass-through
return ValidateWalkable(footCenter, sphereRadius, planeOpt.Value, isWater: false,
// Per-point water depth: 0.9 on fully water cells, 0.45 on partial-
// water near a water corner, 0.1 on partial-water near a dry corner,
// 0 on dry cells. ValidateWalkable adds this to its signed-distance
// check so the character is allowed to sink this far below the
// contact plane before the push-up fires. In retail, this is what
// makes characters appear submerged in water — there is NO separate
// water surface mesh; the character just sits lower than terrain.
float waterDepth = engine.SampleWaterDepth(footCenter.X, footCenter.Y);
bool isWater = waterDepth >= 0.45f;
return ValidateWalkable(footCenter, sphereRadius, planeOpt.Value,
isWater, waterDepth,
cellId: sp.CheckCellId);
}
@ -649,7 +660,7 @@ public sealed class Transition
/// </summary>
private TransitionState ValidateWalkable(Vector3 sphereCenter, float sphereRadius,
System.Numerics.Plane contactPlane,
bool isWater, uint cellId)
bool isWater, float waterDepth, uint cellId)
{
var sp = SpherePath;
var ci = CollisionInfo;
@ -658,9 +669,16 @@ public sealed class Transition
// Low point of the sphere.
var lowPoint = sphereCenter - new Vector3(0f, 0f, sphereRadius);
// Signed distance: positive = above, negative = below.
// Plane convention: dot(N, p) + D.
float dist = Vector3.Dot(lowPoint, contactPlane.Normal) + contactPlane.D;
// Signed distance + waterDepth: positive = above (or within water
// column), negative = below even counting the allowed submersion.
// This is the key retail quirk (ACE ObjectInfo.ValidateWalkable
// line 124): adding waterDepth to the distance pushes the
// "below plane" threshold DOWN by the water depth, so the
// character is allowed to sit up to `waterDepth` meters below the
// terrain without a push-up firing. 0.9 on fully-water cells
// produces the "submerged" visual; 0.1 on dry cells gives a subtle
// natural sink-in instead of sitting exactly on the plane.
float dist = Vector3.Dot(lowPoint, contactPlane.Normal) + contactPlane.D + waterDepth;
// ── Above or touching the surface ────────────────────────────────
if (dist >= -PhysicsGlobals.EPSILON)
@ -1096,21 +1114,47 @@ public sealed class Transition
}
// Safety check: ensure the sphere stays above the contact plane.
// Ported from pseudocode section 6 (AdjustOffset safety block).
// Ported from pseudocode section 6 (AdjustOffset safety block), with
// a correction for the Z-axis sphere-origin convention.
//
// The LocalSphere origin is at (0, 0, radius): the sphere center
// sits `radius` above the character root along WORLD Z, NOT along
// the plane normal. When a character stands with feet on a tilted
// plane, the sphere center's perpendicular distance to that plane
// is `radius * Normal.Z`, not `radius`. The naïve `dist < radius`
// threshold therefore fires spuriously on every slope — the sphere
// is geometrically offset, not actually penetrating — and the
// subsequent push-up lifts the feet by `r * (sec θ - 1)`: 7 cm at
// 30°, 20 cm at 45°, 48 cm at 60°. The steep-slope lift is large
// enough to break the "feet on plane → set contact plane" check in
// ValidateWalkable; ValidateTransition then clears OnWalkable,
// gravity applies next frame, and the character visibly flickers
// into the Falling animation while running up hills. Observed
// empirically on steep slopes.
//
// Correct threshold: `radius * Normal.Z` (the natural resting
// distance of a Z-aligned sphere on the given plane). The push
// fires only when the sphere is ACTUALLY penetrating below natural
// resting. ACE and the published pseudocode have the original
// threshold, but the bug would also affect ACE's simulation — it's
// just invisible server-side where no one renders characters.
if (ci.ContactPlaneCellId != 0 && !ci.ContactPlaneIsWater)
{
Vector3 globCenter = sp.GlobalSphere[0].Origin;
float radius = sp.GlobalSphere[0].Radius;
// Signed distance from sphere center to contact plane.
// For outdoor terrain within the same landblock, block offset is zero.
float dist = Vector3.Dot(globCenter, ci.ContactPlane.Normal)
+ ci.ContactPlane.D;
if (dist < radius - PhysicsGlobals.EPSILON)
// Natural resting distance of the Z-aligned sphere on this plane.
float naturalRestingDist = radius * ci.ContactPlane.Normal.Z;
if (dist < naturalRestingDist - PhysicsGlobals.EPSILON)
{
// Sphere is below the contact plane — push it up.
float zDist = (radius - dist) / ci.ContactPlane.Normal.Z;
// Sphere is actually penetrating the plane (feet below it)
// — push up along +Z to restore natural resting distance.
float zDist = (naturalRestingDist - dist) / ci.ContactPlane.Normal.Z;
if (radius > MathF.Abs(zDist))
{
sp.AddOffsetToCheckPos(new Vector3(0f, 0f, zDist));