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:
parent
30d3d114b0
commit
36255af0f6
19 changed files with 5390 additions and 393 deletions
|
|
@ -164,16 +164,21 @@ public sealed class ProjectileControllerTests
|
|||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 2.0));
|
||||
IRuntimeProjectile runtime = record.ProjectileRuntime!;
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
worldPosition: new Vector3(10f, 202f, 5f),
|
||||
cellLocalPosition: new Vector3(10f, 10f, 5f),
|
||||
orientation: Quaternion.Identity,
|
||||
velocity: new Vector3(1f, 2f, 3f),
|
||||
fullCellId: CellB,
|
||||
currentTime: 2.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
// C4 route 5: accepted-Position placement now routes through
|
||||
// RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition
|
||||
// (Runtime-owned, tested at tests/AcDream.Runtime.Tests). This test is
|
||||
// about Tick/hydration behaviour for a pending destination, not about
|
||||
// the placement seam itself, so the destination is written directly —
|
||||
// mirroring what a committed placement leaves on the canonical body —
|
||||
// and the presentation ack uses the SAME production method the seam's
|
||||
// caller uses (SyncPresentationFromResolvedBody).
|
||||
runtime.Body.SnapToCell(
|
||||
CellB,
|
||||
new Vector3(10f, 202f, 5f),
|
||||
new Vector3(10f, 10f, 5f));
|
||||
runtime.Body.Orientation = Quaternion.Identity;
|
||||
Assert.True(fixture.Live.RebucketLiveEntity(Guid, CellB));
|
||||
Assert.True(fixture.Controller.SyncPresentationFromResolvedBody(record, 2.1));
|
||||
|
||||
Assert.False(record.IsSpatiallyVisible);
|
||||
Assert.True(record.IsSpatiallyProjected);
|
||||
|
|
@ -365,8 +370,16 @@ public sealed class ProjectileControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void FreshVectorAndPositionCorrectionsMutateSameBody()
|
||||
public void FreshVectorCorrectionsMutateTheCanonicalBody()
|
||||
{
|
||||
// C4 route 5: the Position half of this test (a correction commits a
|
||||
// velocity too) is RETIRED by design (D-P5) — a byte-level decode of
|
||||
// retail's MoveOrTeleport (@0x00516330-@0x00516438) confirms it never
|
||||
// reads its velocity argument, and RuntimeRemotePlacementDriveController
|
||||
// .ApplyAcceptedProjectilePosition commits no velocity from a Position
|
||||
// packet. The no-velocity invariant (a Position correction leaves an
|
||||
// in-flight body's velocity bit-identical) is exercised at
|
||||
// tests/AcDream.Runtime.Tests, the seam's own layer, not here.
|
||||
var fixture = new Fixture();
|
||||
LiveEntityRecord record = fixture.Spawn(instance: 3);
|
||||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 4.0));
|
||||
|
|
@ -379,43 +392,20 @@ public sealed class ProjectileControllerTests
|
|||
currentTime: 4.5));
|
||||
Assert.Equal(new Vector3(7f, 8f, 9f), body.Velocity);
|
||||
Assert.Equal(new Vector3(0f, 0f, 2f), body.Omega);
|
||||
|
||||
var correction = new Vector3(30f, 31f, 32f);
|
||||
var correctedVelocity = new Vector3(3f, 4f, 5f);
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
correction,
|
||||
new Vector3(30f, 31f, 32f),
|
||||
Quaternion.Identity,
|
||||
correctedVelocity,
|
||||
CellA,
|
||||
currentTime: 5.0,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
|
||||
Assert.Same(body, record.PhysicsBody);
|
||||
Assert.Equal(correction, body.Position);
|
||||
Assert.Equal(correction, record.WorldEntity!.Position);
|
||||
Assert.Equal(correctedVelocity, body.Velocity);
|
||||
|
||||
// InboundPhysicsStateController normalizes an absent PositionPack
|
||||
// velocity to zero before this contract is called.
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
correction,
|
||||
new Vector3(30f, 31f, 32f),
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
CellA,
|
||||
currentTime: 5.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
Assert.Equal(Vector3.Zero, body.Velocity);
|
||||
}
|
||||
|
||||
// C4 route 5: the AuthoritativeMutation.Position case of this theory is
|
||||
// RETIRED — accepted-Position corrections no longer route through
|
||||
// ProjectileController at all (RuntimeRemotePlacementDriveController
|
||||
// .ApplyAcceptedProjectilePosition owns them). The successor —
|
||||
// "a split quantum straddling an accepted far/teleport Position aborts
|
||||
// Complete rather than clobbering the committed placement" — is a
|
||||
// Runtime-level test (trap T3) at tests/AcDream.Runtime.Tests, exercised
|
||||
// directly against RuntimeProjectilePhysicsUpdater.TryBegin/Complete and
|
||||
// the new seam, since that pairing is what now shares the prediction
|
||||
// version this scenario discards.
|
||||
[Theory]
|
||||
[InlineData(AuthoritativeMutation.Vector)]
|
||||
[InlineData(AuthoritativeMutation.Position)]
|
||||
[InlineData(AuthoritativeMutation.State)]
|
||||
public void AuthoritativeMutationBetweenQuantumHalvesDiscardsPrediction(
|
||||
AuthoritativeMutation mutation)
|
||||
|
|
@ -429,7 +419,6 @@ public sealed class ProjectileControllerTests
|
|||
quantum: 0.05f,
|
||||
out ProjectileController.QuantumStep step));
|
||||
|
||||
Vector3 correctedPosition = new(30f, 31f, 32f);
|
||||
Vector3 correctedVelocity = new(7f, 8f, 9f);
|
||||
Vector3 correctedOmega = new(0f, 0f, 2f);
|
||||
PhysicsStateFlags correctedState = MissileState | PhysicsStateFlags.Gravity;
|
||||
|
|
@ -443,19 +432,6 @@ public sealed class ProjectileControllerTests
|
|||
currentTime: 1.02));
|
||||
break;
|
||||
|
||||
case AuthoritativeMutation.Position:
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
correctedPosition,
|
||||
correctedPosition,
|
||||
Quaternion.Identity,
|
||||
correctedVelocity,
|
||||
CellA,
|
||||
currentTime: 1.02,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
break;
|
||||
|
||||
case AuthoritativeMutation.State:
|
||||
record.FinalPhysicsState = correctedState;
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
|
|
@ -477,10 +453,6 @@ public sealed class ProjectileControllerTests
|
|||
Assert.Equal(correctedVelocity, body.Velocity);
|
||||
Assert.Equal(correctedOmega, body.Omega);
|
||||
break;
|
||||
case AuthoritativeMutation.Position:
|
||||
Assert.Equal(correctedPosition, body.Position);
|
||||
Assert.Equal(correctedVelocity, body.Velocity);
|
||||
break;
|
||||
case AuthoritativeMutation.State:
|
||||
Assert.Equal(correctedState, body.State);
|
||||
break;
|
||||
|
|
@ -856,14 +828,21 @@ public sealed class ProjectileControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedFreshUpdates_DoNotPoisonCanonicalBodyOrPose()
|
||||
public void MalformedFreshVectorUpdate_DoesNotPoisonCanonicalBody()
|
||||
{
|
||||
// C4 route 5: the Position half of this test moved. A malformed
|
||||
// accepted-Position payload for a missile is rejected further
|
||||
// upstream now — the shared CanAcceptPositionPayload gate
|
||||
// (unchanged, its own tests still cover it directly) runs
|
||||
// unconditionally in OnPosition BEFORE the D-P6 dispatch even
|
||||
// decides this is a missile packet, so the malformed-payload swallow
|
||||
// is exercised at that layer (LiveEntityNetworkUpdateController's own
|
||||
// "invalid-payload swallow" test), not here.
|
||||
var fixture = new Fixture();
|
||||
LiveEntityRecord record = fixture.Spawn(instance: 1);
|
||||
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0));
|
||||
PhysicsBody body = record.PhysicsBody!;
|
||||
Vector3 velocity = body.Velocity;
|
||||
Vector3 position = body.Position;
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeVector(
|
||||
record,
|
||||
|
|
@ -871,19 +850,6 @@ public sealed class ProjectileControllerTests
|
|||
Vector3.Zero,
|
||||
currentTime: 2.0));
|
||||
Assert.Equal(velocity, body.Velocity);
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
new Vector3(float.PositiveInfinity, 0f, 0f),
|
||||
new Vector3(float.PositiveInfinity, 0f, 0f),
|
||||
Quaternion.Identity,
|
||||
Vector3.Zero,
|
||||
CellA,
|
||||
currentTime: 2.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
Assert.Equal(position, body.Position);
|
||||
Assert.Equal(position, record.WorldEntity!.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -1333,16 +1299,33 @@ public sealed class ProjectileControllerTests
|
|||
accepted.Position!.Value.LandblockId,
|
||||
_ => throw new InvalidOperationException("re-entry recreated the entity")));
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
record,
|
||||
worldPosition: new Vector3(12f, 10f, 5f),
|
||||
cellLocalPosition: new Vector3(12f, 10f, 5f),
|
||||
orientation: Quaternion.Identity,
|
||||
velocity: Vector3.Zero,
|
||||
fullCellId: CellA,
|
||||
currentTime: 10.0,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
// C4 route 5: the placement itself is Runtime-owned now
|
||||
// (RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition,
|
||||
// tested at tests/AcDream.Runtime.Tests). This test is about the
|
||||
// shadow/clock re-entry after a pickup+leave-world, so the resolved
|
||||
// destination is written directly and the presentation ack reuses
|
||||
// the same production method the seam's caller uses.
|
||||
// ApplyAuthoritativeState (unchanged) rebases the controller's own
|
||||
// `_lastFiniteGameTime` to the packet's receipt time exactly like the
|
||||
// deleted ApplyAuthoritativePosition's `currentTime` parameter used
|
||||
// to — it is a same-value state re-assert (no functional State
|
||||
// change), used here purely as the clock anchor.
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativeState(
|
||||
record, record.FinalPhysicsState, currentTime: 10.0, 1, 1));
|
||||
record.PhysicsBody!.SnapToCell(
|
||||
CellA,
|
||||
new Vector3(12f, 10f, 5f),
|
||||
new Vector3(12f, 10f, 5f));
|
||||
record.PhysicsBody.Orientation = Quaternion.Identity;
|
||||
// D-P5: a Position packet no longer commits velocity (the retired
|
||||
// behaviour this test used to lean on via the deleted method's
|
||||
// `velocity: Vector3.Zero` argument). The Vector channel is a
|
||||
// separate concern (untouched by route 5); stop the body directly so
|
||||
// this test's clock/shadow assertions are not confounded by ordinary
|
||||
// straight-line motion.
|
||||
record.PhysicsBody.set_velocity(Vector3.Zero);
|
||||
Assert.True(fixture.Live.RebucketLiveEntity(Guid, CellA));
|
||||
Assert.True(fixture.Controller.SyncPresentationFromResolvedBody(record, 10.0));
|
||||
// The incarnation-stable RetailObjectQuantumClock is canonical after
|
||||
// the R6 cutover; PhysicsBody.LastUpdateTime is only a legacy absolute
|
||||
// clock mirror and need not equal the packet receipt time once the
|
||||
|
|
@ -1431,8 +1414,20 @@ public sealed class ProjectileControllerTests
|
|||
Assert.Equal(0, fixture.Controller.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 5: the accepted-Position placement itself moved to
|
||||
/// RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition,
|
||||
/// which re-validates currency internally before writing (proof
|
||||
/// obligation P3's argument, tested at tests/AcDream.Runtime.Tests). This
|
||||
/// test now exercises the successor of the OLD method's post-commit
|
||||
/// currency guard: ProjectileController.SyncPresentationFromResolvedBody
|
||||
/// (D-P2's closing paragraph) must refuse to touch presentation once a
|
||||
/// reentrant guid-reuse callback has already replaced the incarnation it
|
||||
/// was called for — a stale ack must never write through to the
|
||||
/// replacement.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AuthoritativePosition_ReentrantGuidReuseStopsOldPostRebucketWork()
|
||||
public void SyncPresentation_ReentrantGuidReuseNeverTouchesTheReplacement()
|
||||
{
|
||||
var fixture = new Fixture();
|
||||
LiveEntityRecord first = fixture.Spawn(instance: 7);
|
||||
|
|
@ -1450,22 +1445,28 @@ public sealed class ProjectileControllerTests
|
|||
replacement = fixture.Spawn(instance: 8);
|
||||
};
|
||||
|
||||
Assert.True(fixture.Controller.ApplyAuthoritativePosition(
|
||||
first,
|
||||
worldPosition: new Vector3(10f, 202f, 5f),
|
||||
cellLocalPosition: new Vector3(10f, 10f, 5f),
|
||||
orientation: Quaternion.Identity,
|
||||
velocity: new Vector3(44f, 0f, 0f),
|
||||
fullCellId: CellB,
|
||||
currentTime: 1.1,
|
||||
liveCenterX: 1,
|
||||
liveCenterY: 1));
|
||||
// The trigger: a landblock unload is the SAME visibility-loss edge
|
||||
// LandblockUnload_SuspendsProjectileAtVisibilityEdgeWithoutFrameScan
|
||||
// uses. RebucketLiveEntity is deliberately NOT the trigger here — it
|
||||
// suppresses ProjectionVisibilityChanged for its OWN guid mid-call
|
||||
// (LiveEntityRuntime.OnSpatialVisibilityChanged's `_rebucketingGuid`
|
||||
// guard), which is exactly why the real placement seam's reentrant
|
||||
// hazard is observable at the SPATIAL edge, not through a caller's
|
||||
// own rebucket.
|
||||
fixture.Spatial.RemoveLandblock(0x0101FFFFu);
|
||||
Assert.True(replaced);
|
||||
|
||||
// The stale ack for the SUPERSEDED incarnation must refuse.
|
||||
Assert.False(fixture.Controller.SyncPresentationFromResolvedBody(first, 1.1));
|
||||
|
||||
Assert.NotNull(replacement);
|
||||
Assert.True(fixture.Live.TryGetRecord(Guid, out var current));
|
||||
Assert.Same(replacement, current);
|
||||
Assert.Null(current.ProjectileRuntime);
|
||||
Assert.Null(current.PhysicsBody);
|
||||
// The replacement's own spawn position (Fixture.Spawn's default),
|
||||
// untouched by the stale ack — the positive half of the assertion,
|
||||
// not merely "the ack returned false".
|
||||
Assert.Equal(new Vector3(10f, 10f, 5f), current.WorldEntity!.Position);
|
||||
Assert.Equal(0, fixture.Controller.Count);
|
||||
}
|
||||
|
|
@ -1580,10 +1581,11 @@ public sealed class ProjectileControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
// C4 route 5: Position is retired from this enum — see the comment on
|
||||
// AuthoritativeMutationBetweenQuantumHalvesDiscardsPrediction.
|
||||
public enum AuthoritativeMutation
|
||||
{
|
||||
Vector,
|
||||
Position,
|
||||
State,
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue