acdream/src/AcDream.Runtime/Physics/RuntimeProjectilePhysicsUpdater.cs
Erik 36255af0f6 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>
2026-08-04 21:03:41 +02:00

373 lines
12 KiB
C#

using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
namespace AcDream.Runtime.Physics;
internal sealed class RuntimeProjectilePhysicsCommit
{
internal required RuntimeProjectilePhysicsUpdater Owner { get; init; }
internal required RuntimeEntityRecord Record { get; init; }
internal required RuntimeProjectile Projectile { get; init; }
internal required ProjectileQuantumPreparation Preparation { get; init; }
internal required ulong PredictionAuthorityVersion { get; init; }
internal required ulong ObjectClockEpoch { get; init; }
internal required Func<bool>? ExternalOwnerValid { get; init; }
internal bool Completed { get; set; }
}
/// <summary>
/// Presentation-free owner of the projectile slice in retail
/// <c>CPhysicsObj::UpdateObjectInternal</c> (<c>0x005156B0</c>). The caller
/// preserves retail hook ordering by holding the returned transaction across
/// <c>process_hooks</c>; Runtime owns integration, transition, prediction
/// versions, canonical cell state, and the physics shadow.
/// </summary>
internal sealed class RuntimeProjectilePhysicsUpdater
{
private readonly RuntimePhysicsState _physics;
private readonly ProjectilePhysicsStepper _stepper;
internal RuntimeProjectilePhysicsUpdater(RuntimePhysicsState physics)
{
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
_stepper = new ProjectilePhysicsStepper(physics.Engine);
}
internal bool TryBegin(
RuntimeEntityRecord record,
float quantum,
ulong objectClockEpoch,
Func<bool>? externalOwnerValid,
out RuntimeProjectilePhysicsCommit commit)
{
ArgumentNullException.ThrowIfNull(record);
if (record.Projectile is not RuntimeProjectile projectile
|| !IsSpatialCurrent(
record,
projectile,
projectile.PredictionAuthorityVersion,
objectClockEpoch,
externalOwnerValid)
|| (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0
|| record.FullCellId == 0)
{
commit = null!;
return false;
}
projectile.Body.State = record.FinalPhysicsState;
bool isParented = record.Snapshot.ParentGuid is not null
|| record.Snapshot.Physics?.Parent is not null;
ProjectileQuantumPreparation preparation = _stepper.BeginQuantum(
projectile.Body,
quantum,
record.FullCellId,
projectile.CollisionSphere,
isParented);
if (!preparation.Simulated
|| !IsSpatialCurrent(
record,
projectile,
projectile.PredictionAuthorityVersion,
objectClockEpoch,
externalOwnerValid))
{
commit = null!;
return false;
}
commit = new RuntimeProjectilePhysicsCommit
{
Owner = this,
Record = record,
Projectile = projectile,
Preparation = preparation,
PredictionAuthorityVersion =
projectile.PredictionAuthorityVersion,
ObjectClockEpoch = objectClockEpoch,
ExternalOwnerValid = externalOwnerValid,
};
return true;
}
internal bool Complete(
RuntimeProjectilePhysicsCommit commit,
int liveCenterX,
int liveCenterY,
Func<RuntimePhysicsFrameSnapshot, bool> acknowledgeProjection)
{
ArgumentNullException.ThrowIfNull(commit);
ArgumentNullException.ThrowIfNull(acknowledgeProjection);
if (!ReferenceEquals(commit.Owner, this))
{
throw new InvalidOperationException(
"A projectile-physics commit belongs to another Runtime owner.");
}
if (commit.Completed)
{
throw new InvalidOperationException(
"A projectile-physics commit has already completed.");
}
commit.Completed = true;
if (!IsSpatialCurrent(
commit.Record,
commit.Projectile,
commit.PredictionAuthorityVersion,
commit.ObjectClockEpoch,
commit.ExternalOwnerValid))
{
return false;
}
PhysicsBody body = commit.Projectile.Body;
ProjectileAdvanceResult result = _stepper.CompleteQuantum(
body,
commit.Preparation,
commit.Projectile.CollisionSphere,
commit.Record.LocalEntityId ?? 0u,
designatedTargetId: 0u);
if (!result.Simulated
|| !IsSpatialCurrent(
commit.Record,
commit.Projectile,
commit.PredictionAuthorityVersion,
commit.ObjectClockEpoch,
commit.ExternalOwnerValid))
{
return false;
}
uint resolvedCellId = result.CellId != 0
? result.CellId
: commit.Record.FullCellId;
body.SnapToCell(
resolvedCellId,
body.Position,
CellLocalFromWorld(
body.Position,
resolvedCellId,
liveCenterX,
liveCenterY));
var snapshot = new RuntimePhysicsFrameSnapshot(
body.Position,
body.Orientation,
resolvedCellId);
if (!_physics.CommitProjectileCell(
commit.Record,
commit.Projectile,
commit.PredictionAuthorityVersion,
resolvedCellId,
commit.ExternalOwnerValid)
|| !IsIdentityCurrent(
commit.Record,
commit.Projectile,
commit.PredictionAuthorityVersion,
commit.ExternalOwnerValid)
|| !acknowledgeProjection(snapshot)
|| !IsIdentityCurrent(
commit.Record,
commit.Projectile,
commit.PredictionAuthorityVersion,
commit.ExternalOwnerValid))
{
return false;
}
if (_physics.IsSpatialProjectile(
commit.Record,
commit.Projectile)
&& (commit.Record.FinalPhysicsState
& PhysicsStateFlags.Hidden) == 0)
{
ShadowPositionSynchronizer.Sync(
_physics.Engine.ShadowObjects,
commit.Record.LocalEntityId ?? 0u,
body.Position,
body.Orientation,
commit.Record.FullCellId,
liveCenterX,
liveCenterY);
}
else
{
_physics.Engine.ShadowObjects.Suspend(
commit.Record.LocalEntityId ?? 0u);
}
return IsIdentityCurrent(
commit.Record,
commit.Projectile,
commit.PredictionAuthorityVersion,
commit.ExternalOwnerValid);
}
internal bool ApplyAuthoritativeVector(
RuntimeEntityRecord record,
ulong expectedVectorAuthorityVersion,
ulong expectedVelocityAuthorityVersion,
Vector3 velocity,
Vector3 angularVelocity,
double currentTime,
Func<bool>? externalOwnerValid = null)
{
ArgumentNullException.ThrowIfNull(record);
if (!TryGetCurrent(
record,
externalOwnerValid,
out RuntimeProjectile projectile)
|| record.VectorAuthorityVersion
!= expectedVectorAuthorityVersion
|| record.VelocityAuthorityVersion
!= expectedVelocityAuthorityVersion)
{
return false;
}
if ((record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0
&& record.RemoteMotion is not null)
{
return false;
}
if (!IsFinite(velocity)
|| !IsFinite(angularVelocity)
|| !double.IsFinite(currentTime))
{
return true;
}
projectile.InvalidatePrediction();
_ = _physics.TryCommitAuthoritativeVector(
record,
projectile.Body,
velocity,
angularVelocity,
currentTime,
externalOwnerValid);
return true;
}
internal bool ApplyAuthoritativeState(
RuntimeEntityRecord record,
ulong expectedStateAuthorityVersion,
PhysicsStateFlags state,
double effectiveClock,
int liveCenterX,
int liveCenterY,
Func<bool>? externalOwnerValid = null)
{
ArgumentNullException.ThrowIfNull(record);
if (!TryGetCurrent(
record,
externalOwnerValid,
out RuntimeProjectile projectile)
|| record.StateAuthorityVersion
!= expectedStateAuthorityVersion)
{
return false;
}
projectile.InvalidatePrediction();
PhysicsBody body = projectile.Body;
if (!double.IsFinite(effectiveClock))
{
effectiveClock = double.IsFinite(body.LastUpdateTime)
? body.LastUpdateTime
: 0d;
}
bool wasMissile =
(body.State & PhysicsStateFlags.Missile) != 0;
body.State = state;
if ((state & PhysicsStateFlags.Missile) != 0 && !wasMissile)
{
body.LastUpdateTime = effectiveClock;
if (record.FullCellId != 0)
{
body.SnapToCell(
record.FullCellId,
body.Position,
CellLocalFromWorld(
body.Position,
record.FullCellId,
liveCenterX,
liveCenterY));
}
}
return true;
}
// C4 route 5 (2026-08-04): the position-packet authority that used to
// live here — ApplyAuthoritativePosition — is deleted. A live missile's
// accepted Position now routes through the canonical
// RuntimeRemotePlacementDriveController.ApplyAcceptedProjectilePosition,
// the same shared placement pipeline (TryExecuteAcceptedRemotePosition /
// StoreAcceptedDestinationPose / CommitCanonical) the remote teleport/far
// arms use, instead of this class's bespoke SnapToCell + CommitProjectileCell
// + manual shadow-sync tail. CommitProjectileCell remains the per-quantum
// path's own cell commit (TryBegin/Complete below) — untouched.
private bool IsSpatialCurrent(
RuntimeEntityRecord record,
RuntimeProjectile projectile,
ulong predictionAuthorityVersion,
ulong objectClockEpoch,
Func<bool>? externalOwnerValid) =>
_physics.IsSpatialProjectile(record, projectile)
&& record.ObjectClockEpoch == objectClockEpoch
&& IsIdentityCurrent(
record,
projectile,
predictionAuthorityVersion,
externalOwnerValid);
private bool IsIdentityCurrent(
RuntimeEntityRecord record,
RuntimeProjectile projectile,
ulong predictionAuthorityVersion,
Func<bool>? externalOwnerValid) =>
_physics.Entities.IsCurrent(record)
&& ReferenceEquals(record.Projectile, projectile)
&& ReferenceEquals(record.PhysicsBody, projectile.Body)
&& projectile.PredictionAuthorityVersion
== predictionAuthorityVersion
&& (externalOwnerValid?.Invoke() ?? true);
private bool TryGetCurrent(
RuntimeEntityRecord record,
Func<bool>? externalOwnerValid,
out RuntimeProjectile projectile)
{
if (_physics.Entities.IsCurrent(record)
&& record.Projectile is RuntimeProjectile current
&& ReferenceEquals(record.PhysicsBody, current.Body)
&& (externalOwnerValid?.Invoke() ?? true))
{
projectile = current;
return true;
}
projectile = null!;
return false;
}
private static Vector3 CellLocalFromWorld(
Vector3 worldPosition,
uint cellId,
int liveCenterX,
int liveCenterY)
{
int landblockX = (int)((cellId >> 24) & 0xFFu);
int landblockY = (int)((cellId >> 16) & 0xFFu);
return worldPosition - new Vector3(
(landblockX - liveCenterX) * 192f,
(landblockY - liveCenterY) * 192f,
0f);
}
private static bool IsFinite(Vector3 value) =>
float.IsFinite(value.X)
&& float.IsFinite(value.Y)
&& float.IsFinite(value.Z);
}