Campaign P Slice P4 item 2. TerrainSurface.SampleWaterDepth now returns 0.1
(was collapsed to 0) for a partially-water cell's dry corner, matching
retail's ObjCell.get_water_depth / calc_water_depth (via ACE's unambiguous
C# port). ValidateWalkable's formula was already byte-for-byte verbatim
(ACE ObjectInfo.ValidateWalkable line 124); only the constant was collapsed.
The old collapse's justification ("0.1 destabilizes the feet-exactly-on-plane
contact-touch check because dist > EPSILON skips SetContactPlane that tick")
is structurally true of retail too - traced and confirmed this slice: in ALL
THREE implementations (retail, ACE, acdream) a skipped touch-reassertion is
NOT a fall, because Contact/OnWalkable are STICKY -
PhysicsEngine.ResolveWithTransition's onGround computation ORs the fresh
per-call ContactPlaneValid with the seeded, persistent
PhysicsBody.TransientState.OnWalkable bit (itself written back by the
caller's own sticky TransientState). PhysicsEngine.SampleTerrainWalkable's
isWater = waterDepth >= 0.45f threshold means the restore does not flip the
dry corner's water classification (0.1 still < 0.45) - only the sink-in
depth changes. Full Core.Tests suite green (4038/2 skips, up from 4026)
proves the sticky-bit argument held in practice.
WATER_CONTACT_TS (TransientStateFlags.WaterContact, declared but never
written) is now mirrored alongside CONTACT_TS/ON_WALKABLE_TS at every commit
point that writes them: PhysicsObjUpdate.ApplySetPositionContact (projectiles
+ remote teleport), PhysicsObjUpdate.CommitSetPositionTransition (remote
teleport placement), and PhysicsEngine's per-resolve body-state commit (local
player + remote dead-reckoning + ordinary movers via ResolveWithTransition -
the actual SetPositionInternal-equivalent path). No signature changes needed:
body.ContactPlaneIsWater is already fresh by the time each function runs.
CollisionShadowVerifier audit: no change needed. It diffs graph-vs-flat BSP
traversal outcomes (ObjectInfo/CollisionInfo/SpherePath fields already
including ContactPlaneIsWater); it never touches PhysicsBody.TransientState,
and the water-depth constant is computed identically upstream of both
traversal modes, so it cannot introduce a new graph/flat divergence.
Filed #264 for the three items research explicitly left open (none block
this port): no confirmed retail consumer of WATER_CONTACT_TS was found (an
xref scan wasn't attempted - bitmask reads aren't text-greppable); the
CLandCell ENTIRELY_WATER ethereal/swim exemption from terrain collision was
not cross-checked; jump-in-water/swim-animation effects were not
investigated (out of physics/collision scope).
Conformance: Ap10WaterSemanticsTests covers SampleWaterDepth golden values
(NotWater/EntirelyWater/PartiallyWater wet+dry corners), the isWater
threshold non-flip, WaterContact mirroring in both PhysicsObjUpdate
functions, and two settle-to-rest end-to-end PhysicsEngine.ResolveWithTransition
scenarios (water: sinks exactly waterDepth below the plane and sets
WaterContact; dry: rests exactly on the plane and clears any stale
WaterContact bit).
Register: retired AP-10 (92 active AP rows, down from 93).
AcDream.Core.Tests: 4038 passed, 2 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
309 lines
12 KiB
C#
309 lines
12 KiB
C#
using System;
|
|
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
using Xunit;
|
|
using Plane = System.Numerics.Plane;
|
|
|
|
namespace AcDream.Core.Tests.Physics;
|
|
|
|
/// <summary>
|
|
/// Conformance tests for AP-10 (Campaign P Slice P4, 2026-07-30) — two
|
|
/// water-semantics gaps identified in
|
|
/// <c>docs/research/2026-07-29-remote-and-world-specials-pseudocode.md</c> §5:
|
|
///
|
|
/// <list type="number">
|
|
/// <item>
|
|
/// Retail's 0.1 m dry-corner water sink-in
|
|
/// (<see cref="TerrainSurface.SampleWaterDepth"/>) was collapsed to 0 —
|
|
/// now restored. <c>PhysicsEngine.SampleTerrainWalkable</c>'s
|
|
/// <c>isWater = waterDepth >= 0.45f</c> threshold means the dry-corner
|
|
/// restore does NOT flip a dry corner's water classification (0.1 <
|
|
/// 0.45, same as the old 0 < 0.45) — only the sink-in depth changes.
|
|
/// </item>
|
|
/// <item>
|
|
/// <c>WATER_CONTACT_TS</c> (<see cref="TransientStateFlags.WaterContact"/>)
|
|
/// was declared but never written — now mirrored alongside
|
|
/// <see cref="TransientStateFlags.Contact"/> at every commit point:
|
|
/// <see cref="PhysicsObjUpdate.ApplySetPositionContact"/>,
|
|
/// <see cref="PhysicsObjUpdate.CommitSetPositionTransition"/>, and
|
|
/// <c>PhysicsEngine</c>'s per-resolve body-state commit.
|
|
/// </item>
|
|
/// </list>
|
|
/// </summary>
|
|
public class Ap10WaterSemanticsTests
|
|
{
|
|
// ── §1: TerrainSurface.SampleWaterDepth golden values ──────────────────
|
|
|
|
/// <summary>terrainTypes byte whose (byte>>2)&0x1F == 0x10 (WaterRunning, the lowest water type).</summary>
|
|
private const byte WaterTerrainByte = 0x10 << 2; // 0x40
|
|
private const byte DryTerrainByte = 0x00;
|
|
|
|
private static byte[] AllVertices(byte value)
|
|
{
|
|
var arr = new byte[81];
|
|
Array.Fill(arr, value);
|
|
return arr;
|
|
}
|
|
|
|
[Fact]
|
|
public void SampleWaterDepth_NotWaterCell_ReturnsZero()
|
|
{
|
|
var surface = new TerrainSurface(
|
|
new byte[81], new float[256],
|
|
terrainTypes: AllVertices(DryTerrainByte));
|
|
|
|
Assert.Equal(0f, surface.SampleWaterDepth(12f, 12f));
|
|
}
|
|
|
|
[Fact]
|
|
public void SampleWaterDepth_EntirelyWaterCell_ReturnsPoint9()
|
|
{
|
|
var surface = new TerrainSurface(
|
|
new byte[81], new float[256],
|
|
terrainTypes: AllVertices(WaterTerrainByte));
|
|
|
|
Assert.Equal(0.9f, surface.SampleWaterDepth(12f, 12f));
|
|
}
|
|
|
|
[Fact]
|
|
public void SampleWaterDepth_PartiallyWaterCell_WaterCorner_ReturnsPoint45()
|
|
{
|
|
// Cell (0,0): corners are vertices (0,0),(1,0),(1,1),(0,1). Make only
|
|
// (1,1) water so cell (0,0) is PartiallyWater (1 of 4 corners).
|
|
// Sampling near local (18,18) (>= 12 into the 24m cell on both axes)
|
|
// rounds to vertex (1,1) — the water corner.
|
|
var types = new byte[81];
|
|
types[1 * 9 + 1] = WaterTerrainByte; // vertex (x=1, y=1)
|
|
|
|
var surface = new TerrainSurface(new byte[81], new float[256], terrainTypes: types);
|
|
|
|
Assert.Equal(0.45f, surface.SampleWaterDepth(18f, 18f));
|
|
}
|
|
|
|
[Fact]
|
|
public void SampleWaterDepth_PartiallyWaterCell_DryCorner_ReturnsPoint1_RestoredRetailConstant()
|
|
{
|
|
// Same PartiallyWater cell as above, but sample near local (4,4)
|
|
// (< 12 into the cell) which rounds to vertex (0,0) — the DRY corner.
|
|
// AP-10: this is the value that was collapsed to 0 and is now restored.
|
|
var types = new byte[81];
|
|
types[1 * 9 + 1] = WaterTerrainByte; // vertex (x=1, y=1) is the only water corner
|
|
|
|
var surface = new TerrainSurface(new byte[81], new float[256], terrainTypes: types);
|
|
|
|
Assert.Equal(0.1f, surface.SampleWaterDepth(4f, 4f));
|
|
}
|
|
|
|
[Fact]
|
|
public void SampleWaterDepth_DryCorner_StaysBelowTheIsWaterClassificationThreshold()
|
|
{
|
|
// PhysicsEngine.SampleTerrainWalkable classifies isWater as
|
|
// waterDepth >= 0.45f. The restored 0.1f dry-corner value must NOT
|
|
// cross that threshold — only the sink-in depth changes, not whether
|
|
// the point is treated as "water" for contact-plane/animation purposes.
|
|
var types = new byte[81];
|
|
types[1 * 9 + 1] = WaterTerrainByte;
|
|
var surface = new TerrainSurface(new byte[81], new float[256], terrainTypes: types);
|
|
|
|
float dryDepth = surface.SampleWaterDepth(4f, 4f);
|
|
Assert.True(dryDepth < 0.45f, $"Dry-corner depth {dryDepth} must stay below the isWater threshold");
|
|
}
|
|
|
|
// ── §2: WATER_CONTACT_TS mirroring in PhysicsObjUpdate ─────────────────
|
|
|
|
private static PhysicsBody MakeBody(bool contactPlaneIsWater) => new()
|
|
{
|
|
TransientState = TransientStateFlags.None,
|
|
ContactPlaneIsWater = contactPlaneIsWater,
|
|
};
|
|
|
|
[Fact]
|
|
public void ApplySetPositionContact_WaterContactPlane_SetsWaterContactBit()
|
|
{
|
|
var body = MakeBody(contactPlaneIsWater: true);
|
|
|
|
PhysicsObjUpdate.ApplySetPositionContact(body, inContact: true, onWalkable: true);
|
|
|
|
Assert.True(body.IsWaterContact);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplySetPositionContact_DryContactPlane_ClearsWaterContactBit()
|
|
{
|
|
var body = MakeBody(contactPlaneIsWater: false);
|
|
body.TransientState |= TransientStateFlags.WaterContact; // pre-seed stale bit
|
|
|
|
PhysicsObjUpdate.ApplySetPositionContact(body, inContact: true, onWalkable: true);
|
|
|
|
Assert.False(body.IsWaterContact);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplySetPositionContact_WaterContactMirrorsIndependentlyOfContactBit()
|
|
{
|
|
// WaterContact tracks ContactPlaneIsWater, not the inContact argument
|
|
// itself — matches retail writing the two bits from two different
|
|
// per-call locals in the same statement block.
|
|
var body = MakeBody(contactPlaneIsWater: true);
|
|
|
|
PhysicsObjUpdate.ApplySetPositionContact(body, inContact: false, onWalkable: false);
|
|
|
|
Assert.False(body.InContact);
|
|
Assert.True(body.IsWaterContact);
|
|
}
|
|
|
|
[Fact]
|
|
public void CommitSetPositionTransition_WaterContactPlane_SetsWaterContactBit()
|
|
{
|
|
var body = MakeBody(contactPlaneIsWater: true);
|
|
|
|
PhysicsObjUpdate.CommitSetPositionTransition(
|
|
body,
|
|
inContact: true,
|
|
onWalkable: true,
|
|
collisionNormalValid: false,
|
|
collisionNormal: Vector3.Zero,
|
|
previousContact: false,
|
|
previousOnWalkable: false);
|
|
|
|
Assert.True(body.IsWaterContact);
|
|
}
|
|
|
|
[Fact]
|
|
public void CommitSetPositionTransition_DryContactPlane_ClearsWaterContactBit()
|
|
{
|
|
var body = MakeBody(contactPlaneIsWater: false);
|
|
body.TransientState |= TransientStateFlags.WaterContact; // pre-seed stale bit
|
|
|
|
PhysicsObjUpdate.CommitSetPositionTransition(
|
|
body,
|
|
inContact: true,
|
|
onWalkable: true,
|
|
collisionNormalValid: false,
|
|
collisionNormal: Vector3.Zero,
|
|
previousContact: true,
|
|
previousOnWalkable: true);
|
|
|
|
Assert.False(body.IsWaterContact);
|
|
}
|
|
|
|
// ── §3: end-to-end through PhysicsEngine.ResolveWithTransition ─────────
|
|
// (retail's actual SetPositionInternal-equivalent per-resolve commit)
|
|
|
|
private const uint TestLandblockId = 0xA9B40000u;
|
|
private const uint TestCellId = TestLandblockId | 0x0001u;
|
|
private const float SphereRadius = 0.4f;
|
|
private const float SphereHeight = 1.2f;
|
|
|
|
private static PhysicsEngine BuildEngineWithFlatWaterTerrain(bool water)
|
|
{
|
|
var cache = new PhysicsDataCache();
|
|
var engine = new PhysicsEngine { DataCache = cache };
|
|
|
|
var heights = new byte[81]; // all zero -> terrain Z = 0 everywhere
|
|
var heightTable = new float[256];
|
|
var types = water ? AllVertices(WaterTerrainByte) : AllVertices(DryTerrainByte);
|
|
|
|
engine.AddLandblock(
|
|
landblockId: TestLandblockId,
|
|
terrain: new TerrainSurface(heights, heightTable, terrainTypes: types),
|
|
cells: Array.Empty<CellSurface>(),
|
|
portals: Array.Empty<PortalPlane>(),
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f);
|
|
|
|
return engine;
|
|
}
|
|
|
|
private static PhysicsBody MakeGroundedBody(Vector3 position)
|
|
{
|
|
var floorPlane = new Plane(Vector3.UnitZ, 0f);
|
|
var floorVerts = new[]
|
|
{
|
|
new Vector3(-100f, -100f, 0f),
|
|
new Vector3(100f, -100f, 0f),
|
|
new Vector3(100f, 100f, 0f),
|
|
new Vector3(-100f, 100f, 0f),
|
|
};
|
|
|
|
return new PhysicsBody
|
|
{
|
|
Position = position,
|
|
Orientation = Quaternion.Identity,
|
|
ContactPlaneValid = true,
|
|
ContactPlane = floorPlane,
|
|
ContactPlaneCellId = TestCellId,
|
|
WalkablePolygonValid = true,
|
|
WalkablePlane = floorPlane,
|
|
WalkableVertices = floorVerts,
|
|
WalkableUp = Vector3.UnitZ,
|
|
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lets a body sink/settle onto terrain via repeated resolves, exactly the
|
|
/// multi-tick pattern <c>CylSphereFamilyTests.Grounded_WalkIntoWideLowCylinder
|
|
/// _StepsUpOntoTop</c> uses. A single resolve's step-down is bounded
|
|
/// (WalkInterp), so a body starting well above a water-shifted resting
|
|
/// depth needs several ticks to converge — this mirrors retail's own
|
|
/// gradual settle, not an instant snap.
|
|
/// </summary>
|
|
private static void SettleOntoTerrain(PhysicsEngine engine, PhysicsBody body, int ticks = 60)
|
|
{
|
|
Vector3 pos = body.Position;
|
|
uint cellId = TestCellId;
|
|
bool grounded = true;
|
|
for (int tick = 0; tick < ticks; tick++)
|
|
{
|
|
var target = pos + new Vector3(0f, 0.001f, -0.05f);
|
|
var result = engine.ResolveWithTransition(
|
|
pos, target, cellId,
|
|
SphereRadius, SphereHeight,
|
|
stepUpHeight: 0.04f, stepDownHeight: 0.04f,
|
|
isOnGround: grounded,
|
|
body: body,
|
|
moverFlags: ObjectInfoState.IsPlayer,
|
|
movingEntityId: 0);
|
|
body.Position = result.Position;
|
|
pos = result.Position;
|
|
cellId = result.CellId;
|
|
grounded = result.IsOnGround;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void EndToEnd_SettleOntoEntirelyWaterTerrain_SetsBodyWaterContact()
|
|
{
|
|
var engine = BuildEngineWithFlatWaterTerrain(water: true);
|
|
var body = MakeGroundedBody(new Vector3(12f, 12f, 1.0f));
|
|
|
|
SettleOntoTerrain(engine, body);
|
|
|
|
// Settles exactly waterDepth (0.9 m) below the nominal Z=0 terrain plane —
|
|
// the "submerged" visual retail produces (no separate water surface mesh;
|
|
// the character just sits lower than terrain by the allowed sink-in).
|
|
Assert.True(MathF.Abs(body.Position.Z - (-0.9f)) < 0.05f,
|
|
$"Body should settle 0.9m below the nominal terrain plane in an EntirelyWater cell; got Z={body.Position.Z:F3}");
|
|
Assert.True(body.ContactPlaneIsWater, "Body must record the water contact plane");
|
|
Assert.True(body.IsWaterContact, "WATER_CONTACT_TS must mirror ContactPlaneIsWater");
|
|
}
|
|
|
|
[Fact]
|
|
public void EndToEnd_SettleOntoDryTerrain_LeavesBodyWaterContactClear()
|
|
{
|
|
// Dry-land behavior unchanged: an ordinary flat dry landblock must
|
|
// never set WaterContact, exactly as before AP-10.
|
|
var engine = BuildEngineWithFlatWaterTerrain(water: false);
|
|
var body = MakeGroundedBody(new Vector3(12f, 12f, 1.0f));
|
|
body.TransientState |= TransientStateFlags.WaterContact; // pre-seed stale bit
|
|
|
|
SettleOntoTerrain(engine, body);
|
|
|
|
Assert.True(MathF.Abs(body.Position.Z) < 0.05f,
|
|
$"Body should settle exactly on the dry Z=0 terrain plane (no sink-in); got Z={body.Position.Z:F3}");
|
|
Assert.False(body.ContactPlaneIsWater);
|
|
Assert.False(body.IsWaterContact,
|
|
"Dry-land resolves must clear any stale WaterContact bit, not just leave it unset");
|
|
}
|
|
}
|