acdream/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs
Erik 634bc5513a fix(physics): restore a cancelled park instead of leaving the entity withdrawn
Shipped-code defect affecting committed route-2 code, found while reviewing
route 4b-1.

RuntimeSetPositionState.ParkDeferred withdraws an entity from the world:
body.InWorld = false, TransientStateFlags.Active cleared, WithdrawCanonical,
SuspendObjectClock. CancelCoreDeferred then removed the operation and rewrote
the pending Withdraw into a Discard while restoring NONE of it. So cancelling a
wakeable park was strictly worse than keeping one — the park is wakeable, the
cancel destroys the only object that could ever wake it, and the entity is left
invisible AND intangible with nothing to bring it back.

Route 2's re-issue funnel masked this: re-issuing is correct for a one-shot
ForcePosition ACE never repeats, and wrong for a repeated remote stream, so the
hole was hidden rather than fixed.

Retail's own answer is a working park, verified in the decomp rather than
assumed: CPhysicsObj::SetPositionInternal @0x00515BD0, when AdjustPosition
yields no cell @0x00515C1D, calls prepare_to_leave_visibility @0x00515CDA,
store_position @0x00515CE2 (the DESTINATION pose is committed), GotoLostCell
@0x00515CF2 registering at m_position.objcell_id read AFTER store_position (so
the destination cell), clears transient 0x80 @0x00515CF7, and returns OK
@0x00515D07. InitObjCell @0x00508260 drains the lost list on cell load and calls
reenter_visibility @0x00516250, which re-places from the object's OWN
m_position with flags 0x11.

Two corrections to the direction I gave, both forced by evidence and both right:

The pose must NOT be rolled back — only the withdrawal. Three shipped route-2
tests capture positionAtPark AFTER the park and assert it survives the cancel,
and retail agrees: store_position commits the destination and nothing
un-commits it. Restoring residency at the body's committed cell is therefore
retail's own cell choice, not merely self-consistent.

The gate defaults to FALSE with four explicit opt-ins, rather than defaulting
true with opt-outs at the withdrawal callers. That keeps every one of the ~20
shipped Forget/ForgetExactPlacement sites at exactly its current behaviour
instead of depending on having correctly enumerated the withdrawal transactions.
Review had already found the broad version corrupting five of them
(TryApplyPickup, CommitAcceptedParent, CommitAcceptedParentCellless,
CommitWithdrawal, CommitPositionChannelUpdate): they hand-roll a partial
re-withdrawal that undoes the clock and FullCellId but not InWorld or the
_spatialRoots re-registration, leaving a picked-up item both in inventory and an
InWorld cellless spatial root in the physics workset.

ParkDeferred's restorableOnCancel is opt-in for exactly one of its four callers
— the plain unplaceable-destination park. Every quiescence and retirement park
is excluded deliberately: those entities are withdrawn because their world is
going away, and restoring residency inside a quiescing prefix blocks its
retirement.

VerifyPositionChannelCancellation now asserts InWorld and IsSpatialRoot per
channel — Position is a cancellation and must restore; Pickup and Parent are
withdrawals and must not. It previously asserted only !IsDeferred and counts,
which is why five green states hid this.

Register row AP-136 measured against GotoLostCell/reenter_visibility rather than
labelled "retail-shaped". Files #309 (the restore-on-cancel residual, with
park-survives recorded as the retail-faithful target and its two blockers named:
the NewerPositionPickupAndParentEachCancelExactLostOperation invariant and
teardown convergence) and #310 (an unbounded retirement stall — a retained
preparation retry pins its prefix through HasOldPrefixPlacementDebt forever, and
TickLostCellDeadlines has no production caller so the 25 s timer never fires).

This is a user-observable change to shipped paths: restorableOnCancel: true sits
in SubmitPreparedPlacementCore, the shared core behind every production
placement. AP-136 and #309 carry the proposed two-client check.

Gates: complete Release solution 10,973 passed / 4 skipped / 0 failed (baseline
10,938). Every new test discrimination-verified by reverting the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 04:07:39 +02:00

252 lines
9.2 KiB
C#

using AcDream.Core.Net;
using AcDream.Core.Physics;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Entities;
/// <summary>
/// Stable identity issued by the canonical runtime directory. The local id is
/// claimed when a graphical host first materializes the incarnation; a
/// no-window host may claim it immediately.
/// </summary>
public readonly record struct RuntimeEntityKey(
uint LocalEntityId,
ushort Incarnation);
/// <summary>
/// Presentation-free canonical state for one accepted server-object
/// incarnation. App projection, animation, effects, visibility, and hydration
/// state must never be stored here.
/// </summary>
public sealed class RuntimeEntityRecord
{
private readonly Queue<RetailPhysicsStateTransition> _pendingStateTransitions = new();
internal RuntimeEntityRecord(WorldSession.EntitySpawn snapshot)
{
ServerGuid = snapshot.Guid;
Snapshot = snapshot;
RefreshDerivedState();
PositionAuthorityVersion = snapshot.Position is null ? 0UL : 1UL;
VectorAuthorityVersion = snapshot.Physics is null ? 0UL : 1UL;
VelocityAuthorityVersion = snapshot.Position is null
&& snapshot.Physics is null
? 0UL
: 1UL;
MovementAuthorityVersion = 1UL;
ObjDescAuthorityVersion = 1UL;
FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState;
ApplyRawPhysicsState(RawPhysicsState);
}
public uint ServerGuid { get; }
public ushort Incarnation => Snapshot.InstanceSequence;
public ushort Generation => Incarnation;
public WorldSession.EntitySpawn Snapshot { get; internal set; }
public uint? LocalEntityId { get; internal set; }
public RuntimeEntityKey? Key => LocalEntityId is { } localId
? new RuntimeEntityKey(localId, Incarnation)
: null;
public uint FullCellId { get; internal set; }
public uint CanonicalLandblockId { get; internal set; }
public uint RawPhysicsState { get; internal set; }
public PhysicsStateFlags FinalPhysicsState { get; internal set; }
public ulong PhysicsOwnershipEpoch { get; private set; }
public ulong SpatialAuthorityVersion { get; private set; }
public ulong PlacementCommitVersion { get; private set; }
public ulong PhysicsStateMutationVersion { get; private set; }
/// <summary>
/// Incarnation-stable retail <c>CPhysicsObj::update_time</c> owner.
/// </summary>
public RetailObjectQuantumClock ObjectClock { get; } = new();
public ulong ObjectClockEpoch { get; private set; }
/// <summary>
/// True after this incarnation has successfully constructed its retail
/// <c>CPartArray</c>. This is a simulation/lifetime fact, not a renderer
/// object reference.
/// </summary>
public bool HasPartArray { get; internal set; }
public PhysicsBody? PhysicsBody { get; private set; }
public bool PhysicsBodyAcquisitionInProgress { get; internal set; }
public IRuntimeRemoteMotion? RemoteMotion { get; internal set; }
public bool RemoteMotionBindingInProgress { get; internal set; }
public IRuntimeProjectile? Projectile { get; internal set; }
public bool ProjectileBindingInProgress { get; internal set; }
public bool RequiresRemotePlacementRuntime { get; internal set; }
public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost { get; internal set; }
public ulong PositionAuthorityVersion { get; private set; }
public ulong StateAuthorityVersion { get; private set; }
public ulong VectorAuthorityVersion { get; private set; }
public ulong VelocityAuthorityVersion { get; private set; }
public ulong MovementAuthorityVersion { get; private set; }
public ulong MovementCommitVersion { get; private set; } = 1UL;
public ulong ParentCommitVersion { get; private set; }
public ulong ObjDescAuthorityVersion { get; private set; }
public ulong CreateIntegrationVersion { get; private set; } = 1UL;
public bool DeleteAcceptedForTeardown { get; internal set; }
internal bool TryDequeueStateTransition(out RetailPhysicsStateTransition transition) =>
_pendingStateTransitions.TryDequeue(out transition);
internal void SuspendObjectClock()
{
ObjectClock.Deactivate();
ObjectClockEpoch++;
}
/// <summary>
/// Re-activates a clock suspended by <see cref="SuspendObjectClock"/>.
///
/// <para>Deliberately NOT an exact inverse:
/// <see cref="RetailObjectQuantumClock.Deactivate"/> preserves the
/// retained sub-quantum <c>_pending</c> time while
/// <see cref="RetailObjectQuantumClock.Activate"/> zeroes it, so resuming
/// DISCARDS whatever fraction of a quantum was outstanding at suspend.
/// That is the retail semantic, not an oversight — retail's
/// <c>set_active(1)</c> rebases <c>update_time</c> to the current timer so
/// the reactivation frame does not catch up suppressed time.</para>
///
/// <para>The epoch is also asymmetric on purpose: suspend bumps
/// unconditionally, resume bumps only on a real inactive-to-active edge
/// (Activate's own return contract), so a redundant resume is not
/// observable as a clock change.</para>
/// </summary>
internal void ResumeObjectClock()
{
if (ObjectClock.Activate())
ObjectClockEpoch++;
}
internal void ResetObjectClockForEnterWorld(bool isStatic)
{
ObjectClock.ResetForEnterWorld(isStatic);
ObjectClockEpoch++;
}
internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState)
{
StateAuthorityVersion++;
PhysicsStateMutationVersion++;
RawPhysicsState = rawState;
RetailPhysicsStateTransition transition = RetailPhysicsStateTransitions.Apply(
FinalPhysicsState,
(PhysicsStateFlags)rawState);
FinalPhysicsState = transition.FinalState;
if (PhysicsBody is not null)
PhysicsBody.State = FinalPhysicsState;
_pendingStateTransitions.Enqueue(transition);
return transition;
}
internal void AdvancePositionAuthority()
{
PositionAuthorityVersion++;
VelocityAuthorityVersion++;
}
internal void AdvanceVectorAuthority()
{
VectorAuthorityVersion++;
VelocityAuthorityVersion++;
}
internal void AdvanceMovementAuthority()
{
MovementAuthorityVersion++;
VelocityAuthorityVersion++;
}
internal void AdvanceMovementCommit() => MovementCommitVersion++;
internal void AdvancePlacementCommit() => PlacementCommitVersion++;
internal void AdvanceParentCommit() => ParentCommitVersion++;
internal void AdvanceObjDescAuthority() => ObjDescAuthorityVersion++;
internal void AdvanceCreateAuthority()
{
PositionAuthorityVersion++;
StateAuthorityVersion++;
VectorAuthorityVersion++;
VelocityAuthorityVersion++;
MovementAuthorityVersion++;
ObjDescAuthorityVersion++;
CreateIntegrationVersion++;
}
internal void SetChildNoDraw(bool noDraw)
{
PhysicsStateMutationVersion++;
FinalPhysicsState = noDraw
? FinalPhysicsState | PhysicsStateFlags.NoDraw
: FinalPhysicsState & ~PhysicsStateFlags.NoDraw;
if (PhysicsBody is not null)
PhysicsBody.State = FinalPhysicsState;
}
internal void SetFinalPhysicsState(PhysicsStateFlags state)
{
if (state == FinalPhysicsState)
return;
PhysicsStateMutationVersion++;
FinalPhysicsState = state;
}
internal void SetPhysicsBody(PhysicsBody? body)
{
if (ReferenceEquals(PhysicsBody, body))
return;
PhysicsBody = body;
PhysicsOwnershipEpoch++;
}
/// <summary>
/// Retail collision reporting clears Missile, AlignPath, and PathClipped
/// directly on the live CPhysicsObj. Keep the canonical record and its
/// borrowed body in the same mutation edge.
/// </summary>
internal bool StopMissileAfterCollision(bool requireCurrentMissile)
{
const PhysicsStateFlags stopped = PhysicsStateFlags.Missile
| PhysicsStateFlags.AlignPath
| PhysicsStateFlags.PathClipped;
if (requireCurrentMissile
&& (FinalPhysicsState & PhysicsStateFlags.Missile) == 0)
return false;
PhysicsStateFlags final = FinalPhysicsState & ~stopped;
if (final == FinalPhysicsState)
return false;
PhysicsStateMutationVersion++;
FinalPhysicsState = final;
if (PhysicsBody is not null)
PhysicsBody.State = FinalPhysicsState;
return true;
}
internal void RefreshDerivedState(bool refreshPosition = true)
{
if (refreshPosition && Snapshot.Position is { } position)
{
SetFullCell(
position.LandblockId,
(position.LandblockId & 0xFFFF0000u) | 0xFFFFu);
}
RawPhysicsState = Snapshot.Physics?.RawState
?? Snapshot.PhysicsState
?? 0u;
}
internal void SetFullCell(
uint fullCellId,
uint canonicalLandblockId)
{
SpatialAuthorityVersion++;
FullCellId = fullCellId;
CanonicalLandblockId = canonicalLandblockId;
}
}