fix(physics): C4 route 5 — projectile authoritative placement (#276 partial)

Ports retail's missile Position handling into the canonical Runtime
placement owner instead of the deleted ApplyAuthoritativePosition
short-circuit. The Create/residence-window halves of the projectile
pipeline (RuntimeProjectile binding, TryBind's adopted-body branch,
the collision/shadow registration) were already canonical from prior
slices; this closes the remaining gap — how an ACCEPTED Position for
an in-flight missile is classified, placed, and presented.

Byte-decode (Step 1 hard gate, before any code was written):
CPhysicsObj::MoveOrTeleport @0x00516330-0x00516438 disassembled from
the PDB-paired binary (Capstone, x86 32-bit thiscall). `ret 0x10`
establishes four stack args; [esp+0x7c] (arg5, the velocity pointer)
is never referenced in any of the three branches (teleport/near/far).
The retail reviewer independently reproduced this by searching the
whole function body for the `24 7c` mod/rm+disp8 encoding a
`[esp+0x7c]` read would require and found zero occurrences. This
retired a fabricated `?? Vector3.Zero` fallback in the deleted method
— retail's PositionPack::UnPack initializes an absent velocity to
zero and MoveOrTeleport never installs it; the projectile's Vector
channel (RuntimeProjectilePhysicsUpdater.ApplyAuthoritativeVector)
remains the sole velocity authority for a missile. D-P5 in the
contract; the Runtime seam commits no velocity from the Position
packet at all.

The unbound-missile fix: RuntimeEntityObjectLifetime's
ClassifyRemoteAcceptedPosition now derives ProjectileAuthoritative
from a CONJUNCTIVE predicate — the Missile bit AND a bound
RuntimeProjectile whose Body is the canonical PhysicsBody — never the
bit alone. Retail places every non-player CPhysicsObj unconditionally
(there is no missile-specific placement gate in MoveOrTeleport or its
callers), so an unbindable or not-yet-bound missile taking the
ordinary remote tail is retail-faithful, not a fallback: the earlier
bit-only discriminator would have silently frozen it instead.

AP-141 records this as a deliberate, recorded divergence, not
fidelity. Retail mechanically WOULD arm a missile's ConstrainTo leash
on any nonzero MoveOrTeleport return: HandleReceivedPosition
@0x00453FD0's only kind test is player-vs-not, ConstrainTo
@0x00454272 has no kind test of its own, and CPhysicsObj::ConstrainTo
@0x00510520 creates a PositionManager on demand via
MakePositionManager @0x00510523 if one doesn't exist. acdream
deliberately does not construct that EntityPhysicsHost/
PositionManager/InterpolationManager chain for a ballistic body — the
route-5b split the C4 route 5 contract rejected — so a live missile
never shows an armed leash and never catches up via the near/
UnroutedCatchUp policy. This divergence is safe specifically because
ACE never sends UpdatePosition for a missile
(references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Tick.cs:
333-334, SendUpdatePosition() commented out inside the
PhysicsState.Missile branch at :265) — every half of this row is
deterministic-test-gated only, never exercised against a real server.

AP-141 also records the surviving ConstrainTo re-anchor divergence
under clause (b): for the adopted-body case (TryBind's shared-body
branch — an ordinary remote whose Missile bit is set by a later
State packet, so it still carries a live RemoteMotion), acdream now
ports retail's teleport-branch and far-branch StopInterpolating
action (Interp.Clear()), but never re-arms or re-anchors the
inherited ConstrainTo leash the way retail's HandleReceivedPosition
@0x00454254/@0x00454272 does on every nonzero return. The risk
column's earlier wording — that a stale leash "would drag the body
toward a stale anchor" — was wrong and is retracted in this same
commit: ConstraintManager.ConstraintPos is write-only in both retail
and the port (never read by AdjustOffset), and
ConstraintManager::adjust_offset @0x00556180 only tapers or zeroes an
already-composed per-tick offset while InContact — a leash brakes
motion the interp/sticky chain already produced, it cannot pull
anything toward the anchor. The real residual is one tick of un-reset
brake accumulator, contact-gated, and it cannot move an airborne
far-snapped missile at all (the clamp branch does not run while
airborne).

NO CONNECTED GATE EXISTS for this route, by design: ACE never sends a
missile UpdatePosition (see above), so retail's own server never
exercises this code path in play. Every proof obligation here is
test-gated only — Runtime and App-level fixtures constructing the
packet directly — never a live client/server capture.

Three review rounds closed 8 MAJOR findings before this landed:
round 1 (A1 App discarded the seam's status; A2/R1 silent swallow on
an unbound missile; A3/R2 the adopted-body teleport_hook never
wired; A4/A5 zero Runtime/App test coverage); round 2 (a
ParentCellId regression introduced by round 1's own R6 finding,
which the retail reviewer retracted the following round as factually
wrong — the fix here is the REVERT to record.FullCellId, not the
relocation round 1 shipped; B2 the far-branch StopInterpolating skip
never extended to the adopted-body case; residual App/Runtime store-
path coverage; a per-packet closure contradicting the file's own
#315 cached-delegate pattern). Round 3 closed on coverage alone (no
defect): the Advance() retry arm's projectile branch — added at
round 2, semantically reordered at round 2's B5 fix (skip prediction
invalidation on a re-parked Contention, since it writes nothing) —
had never been executed by any test; two new tests drive it directly
and are sabotage-verified against both the reordering and the
retry-arm's own SyncProjectilePresentation call site. The one
recorded defect this campaign produced (the ParentCellId regression)
was caused by complying with a review finding that its own author
later retracted — the standing lesson recorded for future rounds is
that review findings are evidence to re-verify against the code, not
commands to obey unconditionally.

Complete Release suite: 11,063 passed / 4 skipped / 0 failed
(baseline 11,036 at 30d3d114, +27 new tests across this campaign).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-04 21:03:41 +02:00
parent 30d3d114b0
commit 36255af0f6
19 changed files with 5390 additions and 393 deletions

View file

@ -500,6 +500,434 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
// both, because there is one call site left to sabotage. Reverted before
// committing.
// ── C4 route 5 (A5 fix): missile matrix ─────────────────────────────
//
// The architecture review's FAIL-level finding: zero App tests drove
// OnPosition with a missile packet, so A1 (App discards the seam's
// status) and A2 (an unbound missile's Position silently dropped) both
// lived in the ~35 lines of "thin glue" the implementer argued were
// covered by two well-tested layers. These tests extend THIS file's own
// fixture — never a lighter one — with a genuine Missile-flagged,
// RuntimeProjectile-bound, RemoteMotion-less incarnation, and assert the
// SAME observable surface the remote scenarios above assert: body/entity
// position and ParentCellId (that pair is the exact A1 assertion), plus
// the projectile-specific half — no RemoteMotion is EVER created
// (invariant 8's mutual exclusion) and no early wire-pose write occurs.
// Destinations for the commit scenarios are airborne (well above
// PublishDestinationCollision's flat terrain), isolating the placement
// assertions from the shared pipeline's ordinary ground-contact response
// (Claim 4's confound, restated here for the App layer).
private const uint MissileGuid = 0x80007101u;
private static readonly Vector3 MissileAirborneDestination =
new(12f, 14f, SpawnHeight + 10f);
[Fact]
public void MissileTeleportCommit_PlacesBodyNoRemoteMotionParentCellIdAgreesWithBody()
{
using var fixture = new Fixture(MissileGuid, isMissile: true);
fixture.PublishDestinationCollision();
fixture.ServiceWindow.Allow(DestinationLandblock);
fixture.Controller.OnPosition(fixture.Update(
MissileAirborneDestination, DestinationCell, teleportSequence: 5,
guid: MissileGuid, isGrounded: true));
Assert.True(fixture.Lifetime.Entities.TryGetActive(
MissileGuid, out RuntimeEntityRecord canonical));
Assert.Null(canonical.RemoteMotion);
Assert.NotNull(canonical.Projectile);
PhysicsBody body = canonical.PhysicsBody!;
Vector3 resolved = MissileAirborneDestination + DestinationWorldOffset;
Assert.Equal(resolved, body.Position);
Assert.Equal(body.Position, fixture.Entity.Position);
// A1's own assertion: ParentCellId agrees with the RESOLVED body's
// OWN cell — the review's concrete wrong-cell scenario, checked
// positively here rather than only on the no-op scenarios below.
Assert.Equal(body.CellPosition.ObjCellId, fixture.Entity.ParentCellId);
Assert.Equal(DestinationCell, fixture.Entity.ParentCellId);
// D-P4: the collision table was force-ended (no seeded owner here to
// assert a 1->0 transition — that half is covered at
// tests/AcDream.Runtime.Tests — but the call must not throw or
// leave the entity uncollidable long-term; Tick below proves it is
// still a live, ordinary object).
ShadowEntry shadowEntry = Assert.Single(
fixture.Shadows.AllEntriesForDebug(),
entry => entry.EntityId == fixture.Entity.Id);
Assert.Equal(body.Position, shadowEntry.Position);
fixture.DrainPlacementFifo();
}
[Fact]
public void MissileFarCommit_PlacesBodyNoRemoteMotionParentCellIdAgreesWithBody()
{
using var fixture = new Fixture(MissileGuid, isMissile: true);
fixture.PublishDestinationCollision();
fixture.ServiceWindow.Allow(DestinationLandblock);
fixture.Controller.OnPosition(fixture.Update(
MissileAirborneDestination, DestinationCell, teleportSequence: 1,
guid: MissileGuid, isGrounded: true));
Assert.True(fixture.Lifetime.Entities.TryGetActive(
MissileGuid, out RuntimeEntityRecord canonical));
Assert.Null(canonical.RemoteMotion);
PhysicsBody body = canonical.PhysicsBody!;
Vector3 resolved = MissileAirborneDestination + DestinationWorldOffset;
Assert.Equal(resolved, body.Position);
Assert.Equal(body.Position, fixture.Entity.Position);
Assert.Equal(body.CellPosition.ObjCellId, fixture.Entity.ParentCellId);
Assert.Equal(DestinationCell, fixture.Entity.ParentCellId);
fixture.DrainPlacementFifo();
}
/// <summary>
/// B1 EnvCell id — an indoor-format low word (0x100+, outside
/// <c>LandDefs.AdjustToOutside</c>'s 1-0x40 outdoor range) staged onto
/// the body BEFORE the store-path dispatch below. This is what makes
/// the regression assertion actually discriminate: for an OUTDOOR
/// source/destination pair, <c>PhysicsBody.Position</c>'s setter
/// delta-syncs <c>CellPosition</c> through <c>AdjustToOutside</c> and
/// happens to re-derive the correct destination landblock anyway (pure
/// geometry, no collision data needed) — so a wrong
/// <c>body.CellPosition.ObjCellId</c> read would coincidentally agree
/// with <c>record.FullCellId</c> and the test would pass either way.
/// An INDOOR source cell takes <c>SyncCellPositionDelta</c>'s OTHER
/// branch (<c>PhysicsBody.cs:295-300</c>): it carries the position delta
/// but never re-derives the cell id, so <c>body.CellPosition.ObjCellId</c>
/// stays PINNED at the stale indoor source cell through the store path.
/// <c>record.FullCellId</c> has no such blind spot — it is merged from
/// the wire unconditionally — so this is the scenario where the two
/// expressions genuinely diverge and MAJOR 1's revert is provable.
/// </summary>
private const uint IndoorSourceCell = SourceLandblock | 0x0100u;
/// <summary>
/// Residual 1 close (round-2 review): the STORE path — <c>Refused</c> —
/// at the App layer, closing both the coverage gap AND standing as the
/// regression test for MAJOR 1 (the <c>ParentCellId</c> revert). The
/// destination is deliberately left outside the service window
/// (<see cref="RemoteServiceWindow.Allow"/> is never called for
/// <see cref="DestinationLandblock"/>), so
/// <c>CanAttemptDestination</c> refuses before the engine ever runs and
/// <c>StoreAcceptedDestinationPose</c> resolves the destination through
/// Runtime's own world frame instead of a commit. <c>Refused</c> is
/// still a storing (A1-admitted) outcome, so the App-level presentation
/// sync runs — the entity's position AND <c>ParentCellId</c> must both
/// move to the DESTINATION (the wire cell), not the stale INDOOR source
/// cell a <c>body.CellPosition.ObjCellId</c> read would have produced
/// (see <see cref="IndoorSourceCell"/>'s doc comment for why an outdoor
/// source cell would not have discriminated here).
/// </summary>
[Fact]
public void MissileFarRefused_StorePathStillMovesEntityToDestinationParentCellIdAgreesWithWireCell()
{
using var fixture = new Fixture(MissileGuid, isMissile: true);
Assert.True(fixture.Lifetime.Entities.TryGetActive(
MissileGuid, out RuntimeEntityRecord canonicalBeforeStage));
PhysicsBody stagedBody = canonicalBeforeStage.PhysicsBody!;
stagedBody.SnapToCell(
IndoorSourceCell, stagedBody.Position, stagedBody.Position);
Assert.Equal(IndoorSourceCell, stagedBody.CellPosition.ObjCellId);
fixture.PublishDestinationCollision();
// Deliberately NOT allowed — CanAttemptDestination refuses.
fixture.Controller.OnPosition(fixture.Update(
MissileAirborneDestination, DestinationCell, teleportSequence: 1,
guid: MissileGuid, isGrounded: true));
Assert.True(fixture.Lifetime.Entities.TryGetActive(
MissileGuid, out RuntimeEntityRecord canonical));
Assert.Null(canonical.RemoteMotion);
Assert.NotNull(canonical.Projectile);
PhysicsBody body = canonical.PhysicsBody!;
Vector3 resolved = MissileAirborneDestination + DestinationWorldOffset;
Assert.Equal(resolved, body.Position);
Assert.True(body.InWorld);
// The store fallback never re-derives an INDOOR cell id — confirms
// the divergence this test is built to exercise actually occurred.
Assert.Equal(IndoorSourceCell, body.CellPosition.ObjCellId);
// The exact B1 regression check: ParentCellId is the DESTINATION
// (wire) cell, matching record.FullCellId — never
// body.CellPosition.ObjCellId, which just asserted it is STILL the
// stale indoor source cell.
Assert.Equal(body.Position, fixture.Entity.Position);
Assert.Equal(DestinationCell, fixture.Entity.ParentCellId);
fixture.DrainPlacementFifo();
}
[Fact]
public void MissileNear_NoOp_BodyUnchangedNoRemoteMotionNoWirePoseWrite()
{
using var fixture = new Fixture(MissileGuid, isMissile: true);
Vector3 spawnPose = fixture.Entity.Position;
var target = new Vector3(4f, 3f, SpawnHeight);
fixture.Controller.OnPosition(fixture.Update(
target, SourceCell, teleportSequence: 1,
guid: MissileGuid, isGrounded: true));
Assert.True(fixture.Lifetime.Entities.TryGetActive(
MissileGuid, out RuntimeEntityRecord canonical));
Assert.Null(canonical.RemoteMotion);
PhysicsBody body = canonical.PhysicsBody!;
Assert.Equal(spawnPose, body.Position);
// A1's regression, asserted directly: no early wire-pose write —
// the render entity was never moved to `target`.
Assert.Equal(spawnPose, fixture.Entity.Position);
Assert.NotEqual(target, fixture.Entity.Position);
}
[Fact]
public void MissileAirborne_NoOp_BodyUnchangedNoRemoteMotion()
{
using var fixture = new Fixture(MissileGuid, isMissile: true);
Vector3 spawnPose = fixture.Entity.Position;
var wirePos = new Vector3(50f, 50f, SpawnHeight);
fixture.Controller.OnPosition(fixture.Update(
wirePos, SourceCell, teleportSequence: 1,
guid: MissileGuid, isGrounded: false));
Assert.True(fixture.Lifetime.Entities.TryGetActive(
MissileGuid, out RuntimeEntityRecord canonical));
Assert.Null(canonical.RemoteMotion);
PhysicsBody body = canonical.PhysicsBody!;
Assert.Equal(spawnPose, body.Position);
Assert.Equal(spawnPose, fixture.Entity.Position);
}
[Fact]
public void MissileNullClassification_Swallowed_NoRemoteMotionNoWriteNoPredictionChange()
{
using var fixture = new Fixture(
MissileGuid, nullClassification: true, isMissile: true);
Vector3 spawnPose = fixture.Entity.Position;
ulong predictionBefore = fixture.Projectile!.PredictionAuthorityVersion;
var wirePos = new Vector3(50f, 50f, SpawnHeight);
fixture.Controller.OnPosition(fixture.Update(
wirePos, SourceCell, teleportSequence: 1,
guid: MissileGuid, isGrounded: true));
Assert.True(fixture.Lifetime.Entities.TryGetActive(
MissileGuid, out RuntimeEntityRecord canonical));
Assert.Null(canonical.RemoteMotion);
PhysicsBody body = canonical.PhysicsBody!;
Assert.Equal(spawnPose, body.Position);
Assert.Equal(spawnPose, fixture.Entity.Position);
Assert.Equal(
predictionBefore, fixture.Projectile.PredictionAuthorityVersion);
}
/// <summary>
/// A2/R1's regression scenario, driven end to end: Missile bit set, but
/// no <c>RuntimeProjectile</c> bound (TryBind refused, or has not run
/// yet — <c>ProjectileController.cs:160-166</c>'s unsupported-Setup
/// case, or the pre-bind window). Retail places every non-player object
/// unconditionally; the fixed classifier must classify this packet
/// Remote and route it through the SAME generic remote placement path
/// an ordinary remote uses — never the frozen silent drop the
/// unconjoined discriminator produced.
/// </summary>
[Fact]
public void MissileUnbound_FallsThroughToRemoteTail_TracksInsteadOfFreezing()
{
using var fixture = new Fixture(MissileGuid, isMissile: false);
Assert.True(fixture.Lifetime.Entities.TryGetActive(
MissileGuid, out RuntimeEntityRecord canonical));
// Flip Missile AFTER construction, deliberately WITHOUT binding a
// RuntimeProjectile — the unbound shape A2 names.
fixture.Lifetime.Entities.SetFinalPhysicsState(
canonical,
canonical.FinalPhysicsState | PhysicsStateFlags.Missile);
Assert.Null(canonical.Projectile);
EntityPhysicsHost host = fixture.InstallHost();
fixture.Remote.Body.TransientState = TransientStateFlags.Active
| TransientStateFlags.Contact;
fixture.PublishDestinationCollision();
fixture.ServiceWindow.Allow(DestinationLandblock);
Vector3 spawnPose = fixture.Entity.Position;
fixture.Controller.OnPosition(fixture.Update(
MissileAirborneDestination, DestinationCell, teleportSequence: 1,
guid: MissileGuid, isGrounded: true));
// Placed via the ordinary remote far-snap arm — not frozen, and
// armed exactly like FarSnap_BothGuids above.
Assert.NotEqual(spawnPose, fixture.Remote.Body.Position);
Assert.Equal(fixture.Remote.Body.Position, fixture.Entity.Position);
Assert.Equal(DestinationCell, fixture.Entity.ParentCellId);
Assert.NotNull(host.PositionManager.Constraint);
fixture.DrainPlacementFifo();
}
/// <summary>
/// A3/R2's adopted-body scenario: an ordinary remote (populated Interp
/// queue, an armed ConstrainTo leash) whose Missile bit is later set by
/// a State packet — <c>ProjectileController.TryBind</c>'s shared-body
/// branch adopts the SAME <c>RemoteMotion</c>/body rather than replacing
/// it. Retail's <c>teleport_hook</c> per-manager guards are satisfied
/// for this shape, so all six actions run; the App-level pre-dispatch
/// hook call (mirroring the remote teleport arm's own
/// <c>RunRemoteTeleportHook</c> wiring) must un-arm the leash and clear
/// the queue before the placement, exactly like retail's ordering.
/// </summary>
[Fact]
public void MissileAdoptedBody_TeleportCommit_UnConstrainsAndClearsInterpQueue()
{
using var fixture = new Fixture(MissileGuid, isMissile: false);
EntityPhysicsHost host = fixture.InstallHost();
fixture.Remote.Body.TransientState = TransientStateFlags.Active
| TransientStateFlags.Contact;
// Arm the leash and populate the queue directly — the pre-teleport
// "live remote" state the adopted-body scenario requires.
host.PositionManager.ConstrainTo(
new AcDream.Core.Physics.Position(
SourceCell, fixture.Remote.Body.Position, Quaternion.Identity),
startDistance: 1f,
maxDistance: 5f);
Assert.NotNull(host.PositionManager.Constraint);
fixture.Remote.Interp.Enqueue(
fixture.Remote.Body.Position + Vector3.UnitX,
heading: 0f,
isMovingTo: false,
currentBodyPosition: fixture.Remote.Body.Position);
Assert.True(fixture.Remote.Interp.IsActive);
// TryBind's shared-body branch: adopt the SAME body into a
// RuntimeProjectile, and set Missile — the record now carries BOTH
// a RemoteMotion and a bound projectile, exactly like production.
Assert.True(fixture.Lifetime.Entities.TryGetActive(
MissileGuid, out RuntimeEntityRecord canonical));
fixture.Lifetime.Entities.SetFinalPhysicsState(
canonical,
canonical.FinalPhysicsState | PhysicsStateFlags.Missile);
fixture.Lifetime.Physics.BindProjectile(
canonical,
canonical.PhysicsBody!,
new ProjectileCollisionSphere(Vector3.Zero, 0.1f, 1f));
Assert.NotNull(canonical.Projectile);
Assert.NotNull(canonical.RemoteMotion);
fixture.PublishDestinationCollision();
fixture.ServiceWindow.Allow(DestinationLandblock);
fixture.Controller.OnPosition(fixture.Update(
MissileAirborneDestination, DestinationCell, teleportSequence: 5,
guid: MissileGuid, isGrounded: true));
// UnConstrain and StopInterpolating both ran. UnConstrain unmarks
// IsConstrained rather than nulling the manager back out (it was
// lazily CREATED by the ConstrainTo test-setup call above, and
// creation is one-way) — this is the exact discriminator
// WireAirborneNullClassified_BothGuids_WritesOnlyAP135Bookkeeping's
// own comment names ("Constraint is lazily created only on a
// genuine arm").
Assert.NotNull(host.PositionManager.Constraint);
Assert.False(host.PositionManager.Constraint!.IsConstrained);
Assert.False(fixture.Remote.Interp.IsActive);
// The placement itself still committed through the projectile arm.
Assert.Equal(
MissileAirborneDestination + DestinationWorldOffset,
canonical.PhysicsBody!.Position);
fixture.DrainPlacementFifo();
}
/// <summary>
/// B1/B2 fix (round-2 review): the far-branch counterpart to
/// <see cref="MissileAdoptedBody_TeleportCommit_UnConstrainsAndClearsInterpQueue"/>.
/// Retail's far branch (<c>SetPositionSimple</c>,
/// <c>player_distance &gt;= 96 m</c>) runs <c>StopInterpolating</c>
/// @0x005163C9-@0x005163CB whenever <c>position_manager != 0</c> — the
/// SAME guard the teleport branch's full <c>teleport_hook</c> shares
/// for its own <c>StopInterpolating</c> action — but the far branch
/// does NOT run the other five teleport_hook actions (no
/// <c>UnConstrain</c>). For a bare missile this is structurally inert
/// (no <c>RemoteMotion</c>), which is why the pre-round-2 AP-141 row
/// could call the far-branch skip "faithful by consequence." The
/// adopted-body case breaks that: it carries a live <c>Interp</c>
/// queue the far branch must ALSO clear, while its armed
/// <c>ConstrainTo</c> leash must stay armed (proving the far branch
/// really does run only <c>StopInterpolating</c>, not the full hook).
///
/// <para>
/// Round-3 nit (C2): "leash still armed" pins acdream's OWN divergence,
/// not retail's behaviour. Retail's <c>HandleReceivedPosition</c>
/// @0x00454254/@0x00454272 re-anchors an existing leash at the object's
/// just-updated position on every nonzero <c>MoveOrTeleport</c> return —
/// including the far branch's. acdream's far arm never calls
/// <c>ConstrainTo</c> at all (D-P4, AP-141 clause (b)), so "still armed"
/// here means "left exactly as staged," not "correctly re-anchored." A
/// future reader should not read this assertion as full far-branch
/// leash fidelity — only the <c>StopInterpolating</c> half is ported.
/// </para>
/// </summary>
[Fact]
public void MissileAdoptedBody_FarCommit_ClearsInterpQueueButLeavesConstraintArmed()
{
using var fixture = new Fixture(MissileGuid, isMissile: false);
EntityPhysicsHost host = fixture.InstallHost();
fixture.Remote.Body.TransientState = TransientStateFlags.Active
| TransientStateFlags.Contact;
host.PositionManager.ConstrainTo(
new AcDream.Core.Physics.Position(
SourceCell, fixture.Remote.Body.Position, Quaternion.Identity),
startDistance: 1f,
maxDistance: 5f);
Assert.NotNull(host.PositionManager.Constraint);
Assert.True(host.PositionManager.Constraint!.IsConstrained);
fixture.Remote.Interp.Enqueue(
fixture.Remote.Body.Position + Vector3.UnitX,
heading: 0f,
isMovingTo: false,
currentBodyPosition: fixture.Remote.Body.Position);
Assert.True(fixture.Remote.Interp.IsActive);
Assert.True(fixture.Lifetime.Entities.TryGetActive(
MissileGuid, out RuntimeEntityRecord canonical));
fixture.Lifetime.Entities.SetFinalPhysicsState(
canonical,
canonical.FinalPhysicsState | PhysicsStateFlags.Missile);
fixture.Lifetime.Physics.BindProjectile(
canonical,
canonical.PhysicsBody!,
new ProjectileCollisionSphere(Vector3.Zero, 0.1f, 1f));
Assert.NotNull(canonical.Projectile);
Assert.NotNull(canonical.RemoteMotion);
fixture.PublishDestinationCollision();
fixture.ServiceWindow.Allow(DestinationLandblock);
// teleportSequence: 1 (unchanged from the fixture's baseline) with a
// cross-landblock destination classifies as the FAR disposition
// (SetPositionSimple) — the same discriminator
// MissileFarCommit_PlacesBodyNoRemoteMotionParentCellIdAgreesWithBody
// uses above, just against the adopted-body shape instead of a bare
// missile.
fixture.Controller.OnPosition(fixture.Update(
MissileAirborneDestination, DestinationCell, teleportSequence: 1,
guid: MissileGuid, isGrounded: true));
// StopInterpolating ran — the queue is cleared.
Assert.False(fixture.Remote.Interp.IsActive);
// UnConstrain did NOT run — the far branch is one action, not six.
// The leash is still armed.
Assert.NotNull(host.PositionManager.Constraint);
Assert.True(host.PositionManager.Constraint!.IsConstrained);
// The placement itself still committed through the projectile arm.
Assert.Equal(
MissileAirborneDestination + DestinationWorldOffset,
canonical.PhysicsBody!.Position);
fixture.DrainPlacementFifo();
}
private sealed class Fixture : IDisposable
{
internal RuntimeEntityObjectLifetime Lifetime { get; }
@ -510,6 +938,13 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
internal WorldEntity Entity { get; }
internal RemoteMotion Remote { get; private set; } = null!;
internal LiveEntityAnimationState? Animated { get; private set; }
/// <summary>
/// C4 route 5 (A5 fix): the bound projectile component for an
/// <c>isMissile</c> fixture — <see langword="null"/> for an ordinary
/// remote fixture. Exposed so tests can assert prediction-version
/// movement without re-deriving it from <see cref="Lifetime"/>.
/// </summary>
internal RuntimeProjectile? Projectile { get; private set; }
private readonly GpuWorldState _spatial;
private readonly uint _guid;
private readonly bool _nullClassification;
@ -517,7 +952,8 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
internal Fixture(
uint guid,
bool nullClassification = false,
bool withAnimation = false)
bool withAnimation = false,
bool isMissile = false)
{
_guid = guid;
_nullClassification = nullClassification;
@ -559,8 +995,16 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
ForcePosition: 1,
ObjDesc: 1,
Instance: 1);
// C4 route 5 (A5 fix): a missile fixture carries the Missile bit
// from spawn — the SAME data-driven bit
// RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition
// (D-P1) reads, so this fixture's record classifies exactly like
// a real missile would once TryBind/BindProjectile below binds
// the component.
PhysicsStateFlags baseState = PhysicsStateFlags.ReportCollisions
| (isMissile ? PhysicsStateFlags.Missile : PhysicsStateFlags.None);
var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.ReportCollisions,
RawState: (uint)baseState,
Position: wirePosition,
Movement: null,
AnimationFrame: null,
@ -593,7 +1037,7 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
null,
null,
0x09000001u,
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
PhysicsState: (uint)baseState,
InstanceSequence: 1,
PositionSequence: 1,
MovementSequence: 1,
@ -606,24 +1050,65 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests
"fixture failed to materialize the remote entity");
Assert.True(Runtime.RebucketLiveEntity(_guid, SourceCell));
var remote = new RemoteMotion();
remote.Body.SnapToCell(SourceCell, Entity.Position, Entity.Position);
remote.CellId = SourceCell;
Runtime.SetRemoteMotionRuntime(_guid, remote);
Remote = remote;
Shadows.Register(
Entity.Id,
0x02000001u,
Entity.Position,
Entity.Rotation,
radius: 0.48f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: SourceLandblock,
collisionType: ShadowCollisionType.Cylinder,
cylHeight: 1.835f,
seedCellId: SourceCell,
isStatic: false);
// C4 route 5 (A5 fix): a missile fixture binds a
// RuntimeProjectile directly through the SAME production Runtime
// entry point ProjectileController.TryBind eventually calls
// (RuntimePhysicsState.BindProjectile) — never a RemoteMotion.
// This is the exact shape invariant 8's mutual exclusion pins:
// Missile-set means a projectile arm and NO RemoteMotion ever
// exists for the entity.
if (isMissile)
{
var body = new PhysicsBody
{
Position = Entity.Position,
Orientation = Entity.Rotation,
LastUpdateTime = 1d,
State = baseState,
TransientState = TransientStateFlags.Active,
};
body.SnapToCell(SourceCell, Entity.Position, Entity.Position);
RuntimeEntityRecord canonical = record.Canonical!;
Lifetime.Entities.SetPhysicsBody(canonical, body);
canonical.ObjectClock.Activate();
Lifetime.Physics.AcknowledgeSpatialProjection(canonical, spatial: true);
Projectile = (RuntimeProjectile)Lifetime.Physics.BindProjectile(
canonical, body, new ProjectileCollisionSphere(Vector3.Zero, 0.1f, 1f));
Shadows.Register(
Entity.Id,
0x02000001u,
Entity.Position,
Entity.Rotation,
radius: 0.1f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: SourceLandblock,
collisionType: ShadowCollisionType.Sphere,
state: (uint)baseState,
seedCellId: SourceCell,
isStatic: false);
}
else
{
var remote = new RemoteMotion();
remote.Body.SnapToCell(SourceCell, Entity.Position, Entity.Position);
remote.CellId = SourceCell;
Runtime.SetRemoteMotionRuntime(_guid, remote);
Remote = remote;
Shadows.Register(
Entity.Id,
0x02000001u,
Entity.Position,
Entity.Rotation,
radius: 0.48f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: SourceLandblock,
collisionType: ShadowCollisionType.Cylinder,
cylHeight: 1.835f,
seedCellId: SourceCell,
isStatic: false);
}
var origin = new LiveWorldOriginState();
origin.SetPlaceholder(