acdream/tests/AcDream.Core.Tests/Physics/Ap10WaterSemanticsTests.cs
Erik cc8d57a26e fix(physics): AP-10 - restore retail's 0.1m dry-corner water sink-in; wire WATER_CONTACT_TS
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>
2026-07-30 10:52:39 +02:00

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 &gt;= 0.45f</c> threshold means the dry-corner
/// restore does NOT flip a dry corner's water classification (0.1 &lt;
/// 0.45, same as the old 0 &lt; 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&gt;&gt;2)&amp;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");
}
}