Flips the SetPositionSimple classification (contact, PlayerDistance >= 96 m) for remotes onto 4b-1's drive controller and deletes both legacy far blocks, both duplicated 96f/4f constant pairs, and both `?? Vector3.Zero` fabrications. The 4 m constant now exists exactly once. Teleport and cell-less stay legacy for 4b-3. Retail: MoveOrTeleport @0x00516330's far branch runs StopInterpolating @0x005163CB before SetPositionSimple @0x005163D9 and returns 1 @0x005163E8 regardless — the SetPositionError is discarded — so HandleReceivedPosition arms ConstrainTo @0x00454272 post-move on commit AND on failure. The x87 parity decode at @0x00516393-@0x0051639E puts exactly 96.0 on the far branch. SetPositionSimple @0x005162B0 builds flags 0x1012 at @0x005162C4. Non-commit outcomes still advance the body, because retail's SetPositionInternal @0x00515BD0 commits the destination via store_position @0x00515CE2 when no cell resolves. The partition is by STAGE, not heuristic, enforced by an exhaustive switch: Refused/Contention/NotApplicable/RejectedPreparation store (the placement never executed); Committed/Deferred/RejectedByPlacement do not (the engine ran and refused, matching retail's non-storing returns @0x00515CB2 and @0x00515CD5). Without this a refused far snap froze the remote with an emptied queue. Also fixes a shipped defect this route made live: ParkDeferred's quiescence parks withdrew the entity (InWorld=false, clock suspended, residency removed) and were never restorable, while Forget(restoreCancelledPark: true) runs for every accepted Position on every entity. The restorable decision now lives inside ParkDeferred AFTER SnapToCell, reading body.CellPosition.ObjCellId — the value RestoreParkWithdrawal actually restores at — against every live quiescence rather than one minimum-OperationId token. The three pre-snap fields are hoisted into locals because SnapToCell ends with InWorld = true. ParkCollisionResidents passes restorableOnCancel: false explicitly; the plain unplaceable park is provably unchanged. RestoreParkWithdrawal re-tests the prefix at restore time so a retained route-2 park cannot re-admit into a prefix that began quiescing during the park. CanAttemptDestination is retained as an OPTIMISATION only, with the two Core predicates it cannot reproduce written down at the pre-flight, plus the two properties that depend on it staying there. Four fix rounds and eight Opus reviews. The slice was fully green at 10,990, 10,997 and 11,004 while containing real defects — a frozen remote pinned as correct by its own test, a fallback that over-wrote on the exact retail paths that decline to store, and a park guard incomplete on two independent axes. Register: AP-137 (leftover classifications take AP-87's catch-up; states the cell-less enqueue-vs-place delta deferred to 4b-3, that RejectedData is applied anyway, and the headless divergence), AP-138 (the refusable far placement), AP-136 narrowed to match the relocation. #309's acceptance steps rewritten — step 5 previously asserted a recovery the code does not perform — and gated on a new ACDREAM_PROBE_PARK=1 signal so the check cannot pass while broken. Suite 11,009 passed / 4 skipped / 0 failed against a measured 10,968 baseline. The 10,973 figure recorded earlier was wrong and is corrected here. Connected gate outstanding: the two-client far-snap walk and #309. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
294 lines
11 KiB
C#
294 lines
11 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Physics;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Physics.Motion;
|
|
using AcDream.Core.World;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Physics;
|
|
|
|
namespace AcDream.App.Tests.Physics;
|
|
|
|
/// <summary>
|
|
/// C4 route 4a: behavioural proof that the generic top-of-<c>OnPosition</c>
|
|
/// render-pose write no longer double-writes a remote whose accepted Position
|
|
/// classifies to
|
|
/// <see cref="RuntimeAuthoritativePositionDisposition.NoPositionOperation"/> or
|
|
/// <see cref="RuntimeAuthoritativePositionDisposition.Interpolate"/>, and
|
|
/// still writes it for every classification route 4a does not own.
|
|
///
|
|
/// <para>
|
|
/// These tests call the PRODUCTION decision-and-mutation entry point
|
|
/// (<see cref="LiveEntityNetworkUpdateController.TryApplyGenericRemoteRenderPose"/>)
|
|
/// against a real <see cref="WorldEntity"/> and real
|
|
/// <see cref="RuntimeAuthoritativePositionRouteClassifier"/> output. Nothing
|
|
/// is re-derived in the test body: revert the suppression inside that method
|
|
/// and the two suppression tests fail; widen it to <c>route is not null</c>
|
|
/// and four of the five legacy-write tests fail (the fifth,
|
|
/// <see cref="NoClassificationAtAll_StillTakesTheLegacyRenderPoseWrite"/>,
|
|
/// passes a null route and so survives that particular widening — it
|
|
/// discriminates the OTHER direction, an over-narrow guard). That is the gap
|
|
/// #292 recorded for route 2's source-text pin.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The suite also pins the ORDERING carve-out
|
|
/// (<see cref="LiveEntityNetworkUpdateController.ApplyRemoteContactRouting"/>):
|
|
/// an airborne body's contact packet classifies <c>Interpolate</c>, so
|
|
/// precedence — not classification — is the only thing keeping a landing
|
|
/// creature planting instead of gliding.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class LiveEntityNetworkRemoteSteadyStateIntegrationTests
|
|
{
|
|
private const uint OldCell = 0x01010001u;
|
|
private const uint WireCell = 0x01010002u;
|
|
private static readonly Vector3 OldPosition = new(5f, 5f, 5f);
|
|
private static readonly Vector3 WirePosition = new(6f, 7f, 8f);
|
|
|
|
[Fact]
|
|
public void NoPositionOperation_LeavesTheRenderEntityPoseAndCellUntouched()
|
|
{
|
|
// Retail's airborne no-op (D1): MoveOrTeleport @0x00516330 returns 0
|
|
// for arg4 == 0 and writes nothing. CommittedCellId is nonzero so the
|
|
// cell-less SetPosition branch cannot be what is selected here.
|
|
RuntimeAuthoritativePositionRoute route =
|
|
Classify(hasContact: false, playerDistance: 1f);
|
|
Assert.Equal(
|
|
RuntimeAuthoritativePositionDisposition.NoPositionOperation,
|
|
route.Disposition);
|
|
|
|
AssertRenderPoseSuppressed(route);
|
|
}
|
|
|
|
[Fact]
|
|
public void Interpolate_LeavesTheRenderEntityPoseAndCellUntouched()
|
|
{
|
|
// Retail's near InterpolateTo queue @0x005163AF. The resolved body,
|
|
// not the raw wire packet, is the branch tail's only writer of the
|
|
// render entity — writing the wire pose here first would be the
|
|
// second writer route 2's original defect consisted of.
|
|
RuntimeAuthoritativePositionRoute route =
|
|
Classify(hasContact: true, playerDistance: 10f);
|
|
Assert.Equal(
|
|
RuntimeAuthoritativePositionDisposition.Interpolate,
|
|
route.Disposition);
|
|
|
|
AssertRenderPoseSuppressed(route);
|
|
}
|
|
|
|
[Fact]
|
|
public void FarSetPositionSimple_StillTakesTheLegacyRenderPoseWrite()
|
|
{
|
|
RuntimeAuthoritativePositionRoute route =
|
|
Classify(hasContact: true, playerDistance: 200f);
|
|
Assert.Equal(
|
|
RuntimeAuthoritativePositionDisposition.SetPositionSimple,
|
|
route.Disposition);
|
|
|
|
AssertRenderPoseWritten(route);
|
|
}
|
|
|
|
[Fact]
|
|
public void CellLessRemote_StillTakesTheLegacyRenderPoseWrite()
|
|
{
|
|
RuntimeAuthoritativePositionRoute route = Classify(
|
|
hasContact: false,
|
|
playerDistance: 1f,
|
|
committedCellId: 0u);
|
|
Assert.Equal(
|
|
RuntimeAuthoritativePositionDisposition.SetPosition,
|
|
route.Disposition);
|
|
|
|
AssertRenderPoseWritten(route);
|
|
}
|
|
|
|
[Fact]
|
|
public void RejectedAuthority_StillTakesTheLegacyRenderPoseWrite()
|
|
{
|
|
RuntimeAuthoritativePositionRoute route = Classify(
|
|
hasContact: true,
|
|
playerDistance: 10f,
|
|
disposition: PositionTimestampDisposition.Rejected);
|
|
Assert.Equal(
|
|
RuntimeAuthoritativePositionDisposition.RejectedAuthority,
|
|
route.Disposition);
|
|
|
|
AssertRenderPoseWritten(route);
|
|
}
|
|
|
|
[Fact]
|
|
public void RejectedData_StillTakesTheLegacyRenderPoseWrite()
|
|
{
|
|
RuntimeAuthoritativePositionRoute route =
|
|
Classify(hasContact: true, playerDistance: float.NaN);
|
|
Assert.Equal(
|
|
RuntimeAuthoritativePositionDisposition.RejectedData,
|
|
route.Disposition);
|
|
|
|
AssertRenderPoseWritten(route);
|
|
}
|
|
|
|
[Fact]
|
|
public void NoClassificationAtAll_StillTakesTheLegacyRenderPoseWrite()
|
|
{
|
|
// The login window (no local-player controller yet) and a canonical
|
|
// record that has not claimed a local id both produce a null route.
|
|
// Its doc comment promises the legacy path runs unchanged — so the
|
|
// write must still happen.
|
|
AssertRenderPoseWritten(null);
|
|
}
|
|
|
|
// ── Ordering: the airborne snap outranks route 4a's near branch ─────────
|
|
|
|
[Fact]
|
|
public void AirborneBodyWithAContactPacket_SnapsInsteadOfEnqueuing()
|
|
{
|
|
// The NPC LANDING packet: the client still believes the body airborne
|
|
// and ACE reports it in contact, close by. That classifies
|
|
// Interpolate — and AP-87's conditions are all benign (0.5 m, sampled,
|
|
// DR-ticked) — so route 4a's branch would ENQUEUE it. It must SNAP:
|
|
// a drudge knocked off a ledge plants, it does not glide to the
|
|
// ground over a packet interval.
|
|
RuntimeAuthoritativePositionRoute route =
|
|
Classify(hasContact: true, playerDistance: 10f);
|
|
Assert.Equal(
|
|
RuntimeAuthoritativePositionDisposition.Interpolate,
|
|
route.Disposition);
|
|
|
|
using var fixture = new RemotePlacementDriveFixture();
|
|
(RuntimeEntityRecord record, RemoteMotion remote, _) =
|
|
fixture.AddRemote(0x70005001u, new Vector3(12f, 14f, 7f));
|
|
remote.Body.Position = new Vector3(10f, 10f, 5f);
|
|
remote.Airborne = true;
|
|
var landing = new Vector3(10.5f, 10f, 5f); // 0.5 m — well within 4 m.
|
|
|
|
LiveEntityNetworkUpdateController.RemoteContactRouting routing =
|
|
LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
|
|
fixture.Drive,
|
|
record,
|
|
remote,
|
|
route,
|
|
landing,
|
|
Quaternion.Identity,
|
|
willBeDrTicked: true);
|
|
|
|
Assert.Equal(
|
|
LiveEntityNetworkUpdateController.RemoteContactArm.AirborneSnap,
|
|
routing.Arm);
|
|
Assert.Equal(landing, remote.Body.Position);
|
|
}
|
|
|
|
[Fact]
|
|
public void GroundedBodyWithANearContactPacket_TakesRoute4asInterpolateBranch()
|
|
{
|
|
// The same packet on a body the client already considers grounded is
|
|
// route 4a's own branch: queued for the per-tick catch-up, body
|
|
// untouched. This is what proves the airborne test above is a
|
|
// PRECEDENCE carve-out and not a blanket disable of route 4a.
|
|
using var fixture = new RemotePlacementDriveFixture();
|
|
(RuntimeEntityRecord record, RemoteMotion remote, _) =
|
|
fixture.AddRemote(0x70005002u, new Vector3(12f, 14f, 7f));
|
|
remote.Body.Position = new Vector3(10f, 10f, 5f);
|
|
remote.Airborne = false;
|
|
var target = new Vector3(10.5f, 10f, 5f);
|
|
|
|
LiveEntityNetworkUpdateController.RemoteContactRouting routing =
|
|
LiveEntityNetworkUpdateController.ApplyRemoteContactRouting(
|
|
fixture.Drive,
|
|
record,
|
|
remote,
|
|
Classify(hasContact: true, playerDistance: 10f),
|
|
target,
|
|
Quaternion.Identity,
|
|
willBeDrTicked: true);
|
|
|
|
Assert.Equal(
|
|
LiveEntityNetworkUpdateController.RemoteContactArm
|
|
.SteadyStateInterpolate,
|
|
routing.Arm);
|
|
Assert.Equal(new Vector3(10f, 10f, 5f), remote.Body.Position);
|
|
}
|
|
|
|
private static void AssertRenderPoseSuppressed(
|
|
RuntimeAuthoritativePositionRoute? route)
|
|
{
|
|
WorldEntity entity = MakeEntity();
|
|
var beforeRotation = entity.Rotation;
|
|
|
|
bool written = LiveEntityNetworkUpdateController
|
|
.TryApplyGenericRemoteRenderPose(
|
|
entity,
|
|
route,
|
|
WirePosition,
|
|
WireCell,
|
|
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 1.1f));
|
|
|
|
Assert.False(written);
|
|
Assert.Equal(OldPosition, entity.Position);
|
|
Assert.Equal(OldCell, entity.ParentCellId);
|
|
Assert.Equal(beforeRotation, entity.Rotation);
|
|
}
|
|
|
|
private static void AssertRenderPoseWritten(
|
|
RuntimeAuthoritativePositionRoute? route)
|
|
{
|
|
WorldEntity entity = MakeEntity();
|
|
var wireRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 1.1f);
|
|
|
|
bool written = LiveEntityNetworkUpdateController
|
|
.TryApplyGenericRemoteRenderPose(
|
|
entity,
|
|
route,
|
|
WirePosition,
|
|
WireCell,
|
|
wireRotation);
|
|
|
|
Assert.True(written);
|
|
Assert.Equal(WirePosition, entity.Position);
|
|
Assert.Equal(WireCell, entity.ParentCellId);
|
|
Assert.Equal(wireRotation, entity.Rotation);
|
|
}
|
|
|
|
private static WorldEntity MakeEntity() => new()
|
|
{
|
|
Id = 1u,
|
|
SourceGfxObjOrSetupId = 1u,
|
|
Position = OldPosition,
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = Array.Empty<MeshRef>(),
|
|
ParentCellId = OldCell,
|
|
};
|
|
|
|
private const uint Cell = 0x0101FFFFu;
|
|
|
|
private static RuntimeAuthoritativePositionRoute Classify(
|
|
bool hasContact,
|
|
float playerDistance,
|
|
uint committedCellId = OldCell,
|
|
PositionTimestampDisposition disposition =
|
|
PositionTimestampDisposition.Apply) =>
|
|
RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition(
|
|
new RuntimeAcceptedPositionRouteRequest(
|
|
new RuntimeAuthoritativePositionAuthority(
|
|
new RuntimeGenerationToken(7),
|
|
new RuntimeEntityKey(0x70000001u, 3),
|
|
PositionAuthorityVersion: 11UL,
|
|
AcceptedPositionSequence: 20,
|
|
PreviousTeleportSequence: 10,
|
|
AcceptedTeleportSequence: 10,
|
|
disposition),
|
|
RuntimePositionEntityKind.Remote,
|
|
RuntimeAcceptedPositionSource.PositionEvent,
|
|
new CreateObject.ServerPosition(
|
|
Cell, 10f, 20f, 30f, 1f, 0f, 0f, 0f),
|
|
PlacementFrame: 0u,
|
|
PositionPackVelocity: Vector3.Zero,
|
|
committedCellId,
|
|
hasContact,
|
|
playerDistance,
|
|
UsePositionFromServer: false,
|
|
HasAnimations: false,
|
|
default));
|
|
}
|