acdream/tests/AcDream.Core.Tests/Physics/RemoteDeOverlapMechanismTests.cs
Erik f51c1dffa5 feat(#184): Slice 3 — Setup-derived mover sphere for the remote de-overlap sweep
The per-tick remote de-overlap sweep used a hardcoded HUMAN collision sphere
(0.48 m radius / 1.835 m capsule top) for EVERY creature, so large and small
monsters de-overlapped at human spacing (register TS-46). Retail seeds the
transition from the object's OWN Setup sphere list scaled by its wire ObjScale
(CPhysicsObj::transition 0x00512dc0 -> init_sphere(GetNumSphere, GetSphere,
m_scale); ObjScale from set_description 0x00514f40).

Slice 3 (one call site, no signature change): before the Path B ResolveWithTransition
call, read the creature's Setup-derived dims via the existing GetSetupCylinder
helper -- (setup.Radius, setup.Height) x ObjScale, the same source the local player
and the moveto/sticky radii already use, consistent with the spawn-time shadow
registration's entScale -- and pass them as sphereRadius/sphereHeight. Fall back to
the human capsule when GetSetupCylinder returns (0,0) for a shapeless / unresolvable
Setup (a zero radius would degenerate the sweep). The player call site is unchanged
(the player IS the human Setup). stepUp/stepDown stay 0.4 m (retail derives those
from the Setup too -- an adjacent divergence left as-is).

Big monsters now spread wider, small ones tighter -- the de-overlap distance tracks
each creature's true radius.

Test: RemoteDeOverlapMechanismTests.ConvergingLargeCreatures_DeOverlapWiderThanHuman
(an R=0.9 pair settles ~1.8 m -- materially wider than the human 0.96 m contact --
proving the sweep de-overlaps at the radius it is given). Register: narrows TS-46
(remotes no longer human-dimmed; residual = the two-scalar reconstruction vs retail's
sphere list, plus the 0.4 m step heights). Core 2621 / App 741 green.

Research: workflow wf_e8306250-21b (3-agent read-only sweep: acdream data source /
retail init_sphere reference / minimal-edit path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:53:21 +02:00

306 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Mechanism validation for the remote-creature de-overlap redo (#184).
///
/// The reported symptom: packed monsters interpenetrate in acdream but barely in retail on
/// the SAME ACE. Retail de-overlaps them CLIENT-side by running the collision sweep on every
/// remote creature every tick against its neighbours' LIVE positions (the shadow == the
/// resolved m_position), with the server position a gentle catch-up target.
///
/// The first attempt (reverted commit 9c0849dd) synced the broadphase shadow to the RAW
/// server position and never to the resolved body — so each creature de-overlapped against a
/// STALE overlapping shadow and any separation was discarded on the next update. These tests
/// prove, in Core, the two load-bearing facts BEFORE wiring GameWindow (so we don't spend
/// another visual gate on an unproven mechanism):
///
/// 1. catch-up (approach the target) + ResolveWithTransition sweep + shadow-follows-resolved
/// => two converging creatures SETTLE at contact-distance (sum of radii), not overlapping
/// — the PROACTIVE de-overlap (each is stopped short of its overlapping target by its
/// neighbour) and PERSISTENCE (the stopped position holds because the shadow tracks it).
///
/// 2. the SAME loop WITHOUT the shadow-follows-resolved sync (shadow left at each creature's
/// START / server-truth position) => the creatures OVERLAP — proving the sync is
/// load-bearing, not a residual (all three Slice-1 reviewers flagged this; it was wrongly
/// deferred).
///
/// Grounded creature-vs-creature, flat terrain, purely-horizontal convergence so the vertical
/// axis is stable and the assertion is about XY separation. Sphere dims = the human Setup
/// (R 0.48, capsule top 1.835 — TS-46). Mirrors the Path B sweep call in GameWindow
/// (isOnGround, EdgeSlide, self-skip via movingEntityId).
/// </summary>
public class RemoteDeOverlapMechanismTests
{
private readonly ITestOutputHelper _out;
public RemoteDeOverlapMechanismTests(ITestOutputHelper output) => _out = output;
private const uint Lb = 0xA9B40000u;
private const uint Cell = Lb | 0x0001u;
private const float R = 0.48f, H = 1.835f, StepUp = 0.60f, StepDown = 0.60f;
private const float ContactDist = 2f * R; // 0.96 m centre-to-centre when just touching
private const float GroundZ = R; // foot sphere resting on the flat (Z=0) terrain
private const float StepPerTick = 0.03f; // catch-up step magnitude toward the target
// The pair freezes at the first-contact position, so the residual overlap at rest is ~one
// catch-up step (validate_transition restores curr_pos on the step that would deepen the
// overlap). Accept up to ~2 steps of slack; a finer step settles nearer ContactDist.
private const float SettleSlack = 2f * StepPerTick + 0.01f;
private static PhysicsEngine BuildEngine()
{
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
engine.AddLandblock(Lb, new TerrainSurface(new byte[81], new float[256]),
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(), 0f, 0f);
return engine;
}
private static void RegisterCreatureAt(PhysicsEngine e, uint id, Vector3 c)
=> e.ShadowObjects.Register(id, 0u, c, Quaternion.Identity, R,
0f, 0f, Lb, ShadowCollisionType.Sphere, 0f, 1f, 0u,
EntityCollisionFlags.IsCreature, isStatic: false);
private static PhysicsBody GroundedBody(Vector3 pos) => new PhysicsBody
{
Position = pos,
Orientation = Quaternion.Identity,
State = PhysicsStateFlags.ReportCollisions,
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable
| TransientStateFlags.Active,
Velocity = Vector3.Zero,
};
/// <summary>One catch-up-step + sweep for one creature (human dims); returns the resolved position.</summary>
private Vector3 StepToward(PhysicsEngine engine, uint id, PhysicsBody body, ref uint cell,
Vector3 target)
=> StepToward(engine, id, body, ref cell, target, R, H);
/// <summary>One catch-up-step + sweep with an explicit mover radius/height (Slice 3).</summary>
private Vector3 StepToward(PhysicsEngine engine, uint id, PhysicsBody body, ref uint cell,
Vector3 target, float radius, float height)
{
Vector3 pre = body.Position;
Vector3 flatTarget = new Vector3(target.X, target.Y, pre.Z);
Vector3 delta = flatTarget - pre;
float dist = delta.Length();
Vector3 post = dist <= StepPerTick ? flatTarget : pre + delta / dist * StepPerTick;
var r = engine.ResolveWithTransition(pre, post, cell, radius, height, StepUp, StepDown,
isOnGround: true, body: body,
moverFlags: ObjectInfoState.EdgeSlide,
movingEntityId: id);
body.Position = r.Position;
if (r.CellId != 0) cell = r.CellId;
return body.Position;
}
[Fact]
public void ConvergingCreatures_WithShadowFollowingResolved_SettleAtContactDistance()
{
var engine = BuildEngine();
// Two creatures 2 m apart (well clear), each catching up toward the SAME centre point
// (10,10) — if either reached it they would coincide (full overlap). The sweep must stop
// each short at contact-distance from the other.
uint idA = 0xA1u, idB = 0xB2u;
var target = new Vector3(10f, 10f, GroundZ);
var a = GroundedBody(new Vector3(9f, 10f, GroundZ));
var b = GroundedBody(new Vector3(11f, 10f, GroundZ));
RegisterCreatureAt(engine, idA, a.Position);
RegisterCreatureAt(engine, idB, b.Position);
uint cellA = Cell, cellB = Cell;
float sepAt = 0f;
for (int tick = 0; tick < 250; tick++)
{
// Creature A: catch-up + sweep against B's CURRENT shadow, then sync A's shadow to
// the resolved body (retail: change_cell from the resolved m_position).
var pa = StepToward(engine, idA, a, ref cellA, target);
engine.ShadowObjects.UpdatePosition(idA, pa, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellA);
var pb = StepToward(engine, idB, b, ref cellB, target);
engine.ShadowObjects.UpdatePosition(idB, pb, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellB);
float s = Vector2.Distance(new(a.Position.X, a.Position.Y), new(b.Position.X, b.Position.Y));
if (tick == 200) sepAt = s; // separation snapshot 50 ticks before the end
if (tick % 50 == 0 || tick == 249)
_out.WriteLine($"tick{tick,3}: A=({a.Position.X:F2},{a.Position.Y:F2}) " +
$"B=({b.Position.X:F2},{b.Position.Y:F2}) sep={s:F3}");
}
float sep = Vector2.Distance(new(a.Position.X, a.Position.Y),
new(b.Position.X, b.Position.Y));
_out.WriteLine($"with-sync final sep={sep:F3} m (contact-distance = {ContactDist:F2} m)");
// The sweep de-overlaps them to a stable equilibrium ~0.10 m inside touching-distance
// ("barely overlapping" — the retail look), from a start where, without the sync, they
// fully coincide. Assert: clearly de-overlapped (>= 0.80 m, i.e. within ~0.16 of contact) ...
Assert.True(sep >= ContactDist - 0.16f,
$"converging creatures must de-overlap to near contact-distance; got {sep:F3} m (contact {ContactDist:F2})");
// ... and STABLE (not collapsing back into overlap over the last 50 ticks).
Assert.True(MathF.Abs(sep - sepAt) < 0.02f,
$"the de-overlapped separation must be stable; drifted {sepAt:F3} -> {sep:F3}");
}
[Fact]
public void ConvergingCreatures_WithoutShadowSync_Overlap_ProvingTheSyncIsLoadBearing()
{
var engine = BuildEngine();
uint idA = 0xA1u, idB = 0xB2u;
var target = new Vector3(10f, 10f, GroundZ);
var a = GroundedBody(new Vector3(9f, 10f, GroundZ));
var b = GroundedBody(new Vector3(11f, 10f, GroundZ));
RegisterCreatureAt(engine, idA, a.Position);
RegisterCreatureAt(engine, idB, b.Position);
uint cellA = Cell, cellB = Cell;
// SAME convergence, but the shadow is NEVER re-synced to the resolved body — it stays at
// each creature's START position (the "shadow at server/stale truth" bug). Each creature
// sweeps against where its neighbour WAS, not where it IS, so nothing stops them reaching
// the shared centre => they end up overlapping.
for (int tick = 0; tick < 250; tick++)
{
StepToward(engine, idA, a, ref cellA, target);
StepToward(engine, idB, b, ref cellB, target);
}
float sep = Vector2.Distance(new(a.Position.X, a.Position.Y),
new(b.Position.X, b.Position.Y));
_out.WriteLine($"no-sync final sep={sep:F3} m (contact-distance = {ContactDist:F2} m)");
// Without the sync each creature sweeps against its neighbour's STALE start-position
// shadow, so nothing stops them reaching the shared centre => they pack well inside
// contact-distance. This is the load-bearing contrast with the synced test above.
Assert.True(sep < 0.40f,
$"without the shadow-follows-resolved sync the creatures should heavily OVERLAP (< 0.40 m — both reach " +
$"the shared centre because each sweeps only its neighbour's stale start-shadow); got {sep:F3} m — " +
$"if this fails the sync may not be the mechanism, rethink before wiring");
}
/// <summary>
/// Integration test through the PRODUCTION catch-up driver (the #184 review's
/// finding-1 gap): the earlier mechanism tests use a synthetic fixed step, but
/// production drives the body from <see cref="RemoteMotionCombiner.ComputeOffset"/>
/// → <see cref="InterpolationManager.AdjustOffset"/>, which has a STALL-BLIP: when
/// the body is blocked short of an OVERLAPPING server waypoint (exactly the
/// de-overlap equilibrium) it makes no progress, node_fail_counter climbs past 3
/// over ~5-frame windows, and it fires a blip-to-tail (a jump straight AT the
/// overlap target) then clears the queue. The concern: if the per-tick sweep does
/// not absorb that blip, the monster pops into its neighbour a few times a second.
/// This drives the REAL loop (Enqueue at UP cadence + ComputeOffset + sweep +
/// shadow-sync) for two creatures converging on a shared point and asserts BOTH
/// that they de-overlap AND that no single tick's net move spikes (the sweep
/// absorbs every blip — matching retail, which runs the same interp + collision).
/// </summary>
[Fact]
public void ConvergingCreatures_RealInterpLoop_DeOverlapsAndAbsorbsTheStallBlip()
{
var engine = BuildEngine();
uint idA = 0xA1u, idB = 0xB2u;
var target = new Vector3(10f, 10f, GroundZ); // shared point → coincide if reached
var a = GroundedBody(new Vector3(9f, 10f, GroundZ));
var b = GroundedBody(new Vector3(11f, 10f, GroundZ));
RegisterCreatureAt(engine, idA, a.Position);
RegisterCreatureAt(engine, idB, b.Position);
uint cellA = Cell, cellB = Cell;
var interpA = new InterpolationManager();
var interpB = new InterpolationManager();
var combA = new RemoteMotionCombiner();
var combB = new RemoteMotionCombiner();
const float maxSpeed = 4f; // motion-table max speed → catch-up ≤ 2× = 8 m/s
const float dt = 1f / 60f;
const int upEvery = 10; // ~6 Hz UpdatePosition cadence
float maxSpikeA = 0f, maxSpikeB = 0f;
for (int tick = 0; tick < 600; tick++) // 10 s — long enough for many stall-blip cycles
{
if (tick % upEvery == 0)
{
// "UpdatePosition": the server keeps reporting the (overlapping) target —
// MoveOrTeleport near-branch enqueues it for the catch-up to chase.
interpA.Enqueue(target, 0f, isMovingTo: false, currentBodyPosition: a.Position);
interpB.Enqueue(target, 0f, isMovingTo: false, currentBodyPosition: b.Position);
}
var preA = a.Position;
a.Position += combA.ComputeOffset(dt, a.Position, Vector3.Zero, a.Orientation, interpA, maxSpeed);
var rA = engine.ResolveWithTransition(preA, a.Position, cellA, R, H, StepUp, StepDown,
isOnGround: true, body: a, moverFlags: ObjectInfoState.EdgeSlide, movingEntityId: idA);
a.Position = rA.Position; if (rA.CellId != 0) cellA = rA.CellId;
engine.ShadowObjects.UpdatePosition(idA, a.Position, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellA);
var preB = b.Position;
b.Position += combB.ComputeOffset(dt, b.Position, Vector3.Zero, b.Orientation, interpB, maxSpeed);
var rB = engine.ResolveWithTransition(preB, b.Position, cellB, R, H, StepUp, StepDown,
isOnGround: true, body: b, moverFlags: ObjectInfoState.EdgeSlide, movingEntityId: idB);
b.Position = rB.Position; if (rB.CellId != 0) cellB = rB.CellId;
engine.ShadowObjects.UpdatePosition(idB, b.Position, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellB);
// Ignore the initial approach (they start 2 m apart and legitimately move
// ~0.13 m/tick); measure the per-tick net move only once they are near the
// equilibrium where the stall-blip fires.
if (tick > 120)
{
maxSpikeA = MathF.Max(maxSpikeA, Vector2.Distance(new(a.Position.X, a.Position.Y), new(preA.X, preA.Y)));
maxSpikeB = MathF.Max(maxSpikeB, Vector2.Distance(new(b.Position.X, b.Position.Y), new(preB.X, preB.Y)));
}
}
float sep = Vector2.Distance(new(a.Position.X, a.Position.Y), new(b.Position.X, b.Position.Y));
_out.WriteLine($"real-interp: sep={sep:F3} m, maxSpike A={maxSpikeA:F3} B={maxSpikeB:F3}");
Assert.True(sep >= ContactDist - 0.16f,
$"real-loop converging creatures must de-overlap to near contact-distance; got {sep:F3} m");
// The stall-blip must be absorbed by the sweep every time — no tick jumps the body
// more than a large catch-up step (8 m/s × dt ≈ 0.13 m; allow generous headroom).
Assert.True(maxSpikeA < 0.30f && maxSpikeB < 0.30f,
$"a stall-blip escaped the sweep (monster popped into its neighbour): " +
$"maxSpike A={maxSpikeA:F3} B={maxSpikeB:F3} m (limit 0.30)");
}
/// <summary>
/// Slice 3 (#184): the de-overlap distance must SCALE with the mover's radius —
/// a LARGE creature (bigger Setup sphere) spreads WIDER than a human, a small one
/// tighter. Production now passes each creature's Setup-derived radius/height
/// (GameWindow.GetSetupCylinder × ObjScale) into this exact sweep instead of the
/// hardcoded human 0.48/1.835. Register + sweep two LARGE creatures (R=0.9) and
/// assert they settle near 2×0.9 = 1.8 m — materially wider than the human 0.96 m
/// contact — proving the sweep de-overlaps at the radius it is given (the property
/// Slice 3 relies on).
/// </summary>
[Fact]
public void ConvergingLargeCreatures_DeOverlapWiderThanHuman()
{
const float bigR = 0.9f, bigH = 3.2f;
const float bigContact = 2f * bigR; // 1.80 m centre-to-centre when touching
var engine = BuildEngine();
uint idA = 0xA1u, idB = 0xB2u;
var target = new Vector3(10f, 10f, bigR); // shared centre → coincide if reached
var a = GroundedBody(new Vector3(7.5f, 10f, bigR)); // start well clear (2.5 m each side)
var b = GroundedBody(new Vector3(12.5f, 10f, bigR));
engine.ShadowObjects.Register(idA, 0u, a.Position, Quaternion.Identity, bigR,
0f, 0f, Lb, ShadowCollisionType.Sphere, 0f, 1f, 0u, EntityCollisionFlags.IsCreature, isStatic: false);
engine.ShadowObjects.Register(idB, 0u, b.Position, Quaternion.Identity, bigR,
0f, 0f, Lb, ShadowCollisionType.Sphere, 0f, 1f, 0u, EntityCollisionFlags.IsCreature, isStatic: false);
uint cellA = Cell, cellB = Cell;
for (int tick = 0; tick < 300; tick++)
{
var pa = StepToward(engine, idA, a, ref cellA, target, bigR, bigH);
engine.ShadowObjects.UpdatePosition(idA, pa, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellA);
var pb = StepToward(engine, idB, b, ref cellB, target, bigR, bigH);
engine.ShadowObjects.UpdatePosition(idB, pb, Quaternion.Identity, 0f, 0f, Lb, seedCellId: cellB);
}
float sep = Vector2.Distance(new(a.Position.X, a.Position.Y), new(b.Position.X, b.Position.Y));
_out.WriteLine($"large-creature sep={sep:F3} m (big contact {bigContact:F2}, human contact {ContactDist:F2})");
Assert.True(sep >= bigContact - 0.20f,
$"large creatures must de-overlap near their 2R contact ({bigContact:F2} m); got {sep:F3} m");
Assert.True(sep > ContactDist + 0.4f,
$"large creatures must spread materially WIDER than the human contact ({ContactDist:F2} m); got {sep:F3} m");
}
}