acdream/tests/AcDream.Core.Tests/Physics/RemoteDeOverlapMechanismTests.cs
Erik 37a94e1fa4 feat(#184): remote-creature de-overlap — placement-snap + shadow-follows-resolved (visual gate passed)
Packed monster remotes interpenetrate in acdream but barely in retail on the same
ACE. Retail de-overlaps them CLIENT-side: it sweeps every remote creature every
tick against neighbours' LIVE resolved positions (the collision shadow == the
resolved m_position, re-registered every moved transition step), with the server
position a gentle catch-up target (CPhysicsObj::MoveOrTeleport 0x00516330), not a
hard-snap. The collision math was already faithful; the bug was the reconciliation
(hard-snap) + the movement model (synth-velocity) + a stale shadow.

A first attempt (reverted) enqueued EVERYTHING and left the shadow at the raw
server position — it gate-failed with invisible monsters (an unplaced body blipped
over a huge distance into the sweep -> garbage pos) and the player stuck on offset
shadows. This redo fixes both root causes, mechanism-proven in a Core test first:

- NPC UpdatePosition routes through MoveOrTeleport with a PLACEMENT-SNAP: the body
  is snapped to the server pos when it is not already near it (first UP, no
  Sequencer to consume the queue, >96 m, or |Body - worldPos| > 4 m); only near DR
  corrections enqueue. This restores the body's placement authority (no invisible
  monsters). Airborne keeps the authoritative hard-snap.
- Grounded movement drives the body from the interp CATCH-UP (ComputeOffset ->
  InterpolationManager::adjust_offset, REPLACE dichotomy) instead of synth-velocity
  (get_state_velocity / SERVERVEL); MovementManager::UseTime runs unconditionally.
- SHADOW-FOLLOWS-RESOLVED: after each tick's sweep the collision shadow is
  re-registered at the resolved body (SyncRemoteShadowToBody), movement-gated
  (|Body - LastShadowSyncPos| > 1 cm). The per-UP :5669 raw-pos sync is now
  PLAYERS-ONLY, so an NPC's shadow is only ever written to its resolved body ->
  neighbours de-overlap against resolved bodies, the spread PERSISTS, and collision
  == render (no stuck-on-nothing). Landing clears the interp queue.

Preserved: airborne path, sticky #171 (gate + StickyManager overwrite of the seeded
frame), omega, the #173 bounce, landing, the node_fail_counter watchdog, and Path A
(player remotes, untouched -- Slice 2 unifies it).

Tests (RemoteDeOverlapMechanismTests): converging pair settles STABLE at 0.86 m
(barely overlapping = the retail look) WITH the shadow-sync vs <0.40 m (full
overlap) WITHOUT it; a third test drives the REAL InterpolationManager loop and
confirms the sweep absorbs the stall-blip (no pop-into-neighbour). 2-lens Opus
review (CONCERNS) addressed: movement-gated re-flood for the town-FPS risk;
players-only :5669; the blip-absorption test.

Register: retires TS-41 (SERVERVEL synth-velocity -> catch-up), narrows TS-44 (NPC
UP unified onto the interp queue; gate kept for orientation), adds AP-86
(shadow-follows-resolved impl) + AP-87 (MoveOrTeleport 4 m/no-Sequencer placement
snap). Known residual: the de-overlap sweep uses the human sphere for the mover, so
large creatures de-overlap at human radii (TS-46; Slice 3 plumbs Setup dims).

Visual gate PASSED (user: monsters visible + spacing much better). Core 2620 /
App 741 green.

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

259 lines
14 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; returns the resolved position.</summary>
private Vector3 StepToward(PhysicsEngine engine, uint id, PhysicsBody body, ref uint cell,
Vector3 target)
{
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, R, H, 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)");
}
}