diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 5e777a9..966c54a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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(); var portalPlanes = new List(); diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 1ae9a03..c938c36 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -110,6 +110,29 @@ public sealed class PhysicsEngine return null; } + /// + /// 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 + /// ObjCell.get_water_depth. Used by + /// to visually submerge characters in water + /// without needing a separate water surface mesh. + /// + 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; + } + /// /// Sample the outdoor terrain plane (Z + sloped normal) at the given /// world-space XY position. The returned diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs index 6068c40..6a37506 100644 --- a/src/AcDream.Core/Physics/TerrainSurface.cs +++ b/src/AcDream.Core/Physics/TerrainSurface.cs @@ -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 + }; + } } /// @@ -209,6 +250,55 @@ public sealed class TerrainSurface return (z, normal); } + /// + /// 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. adds this to the + /// signed-distance check so water cells visually submerge the + /// character while dry terrain keeps feet exactly on the plane. + /// + /// + /// Ported from ACE ObjCell.get_water_depth and + /// LandblockStruct.calc_water_depth, 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. + /// + /// + /// NotWater cell → 0.0f + /// EntirelyWater cell → 0.9f (fully submerged) + /// PartiallyWater cell, nearest-corner water type → 0.45f + /// PartiallyWater cell, nearest-corner non-water → 0.0f + /// (retail uses 0.1, we use 0 to avoid the above-EPSILON dist bug) + /// + /// + 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; + } + /// /// Compute the outdoor cell ID for the given landblock-local position. /// diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 6e0e670..62a9c8b 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -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 /// 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));