Builds the machinery route 4b-2 and 4b-3 will flip on, and changes no remote behaviour: it has no production caller, so RemotePlacementDrivePendingCount is provably 0 and IsConverged is unchanged. Five pieces: a per-entity remote placement owner (RuntimeRemotePlacementDriveController), a Position-time service-window guard with a Runtime interface plus BOTH host implementations, N3's headless RetryPending pump, parked-count observability in the ownership ledger, and the service-window optimisation that avoids parks we can cheaply predict. Landed alone because it is where the park-withdraws-the-entity failure was decided; that decision is fixed at the source in the preceding commit and must not share a review signal with a behaviour flip. Two parts of route 2's controller are deliberately NOT ported, both verified against retail rather than assumed. There is no ack: SendPositionEvent is called only inside HandleReceivedPosition's local-player FORCE_POSITION gate @0x0045400C-@0x00454091, and the remote arm @0x0045414D has no equivalent. There is no re-issue funnel: retail never re-attempts a position it could not apply — stale timestamps merely bump error_count @0x004542AC — and re-issuing packet N after N+1 has merged would apply a pose the newer packet already superseded, which is correct for a one-shot ForcePosition and wrong for a 5-10 Hz stream. The service-window guard is an OPTIMISATION, not the correctness mechanism. The original contract had it the other way round, justified by a claim that retail cannot represent "arrived but not placeable" — false, and corrected in the review findings: retail's GotoLostCell/reenter_visibility path represents it exactly. A pre-flight guard also cannot be complete, because Core defers on the entity's CURRENT cell, on the swept QueriedCellIds footprint spanning neighbouring landblocks, and on residency evaluated after AdjustToOutside — conditions only Core can see. Review found and this commit fixes: DetachRoute cleared two maps of LIVE Core operations without cancelling them (route 2's AbandonPending is the correct mirror, not the first-entry controller) and its test asserted that blindness as convergence; the headless predicate answered "can ever publish" rather than "is published", and after the first fix still matched only 1 of the 9 landblocks this host publishes; OwnsPlacement admitted remote top-level Creates until gated on the Teleport flag as well as the disposition; Advance re-submitted without re-checking the window; and four comments cited a report that did not exist. Contract item 6 is met by the structural proof, not the earlier test: HasOldPrefixPlacementDebt refuses collision-prefix mutation permission before ParkCollisionResidents is ever entered, so its overlap throw is unreachable. That same mechanism is the unbounded stall filed as #310, which 4b-1 does not bound — it only avoids widening it. #311 files the remaining per-tick allocation in RetryPendingProjections; the early-out for the empty-FIFO case landed via a new HasPendingReceipts accessor so hosts still never touch .Placements. directly. Gates: complete Release solution 10,973 passed / 4 skipped / 0 failed (baseline 10,938). Four review rounds; every fix discrimination-verified by revert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
161 lines
6 KiB
C#
161 lines
6 KiB
C#
using AcDream.Runtime.Entities;
|
|
|
|
namespace AcDream.Runtime.Physics;
|
|
|
|
/// <summary>
|
|
/// Presentation-only sink for canonical Runtime SetPosition receipts.
|
|
/// Implementations must apply an exact receipt idempotently: acknowledgement
|
|
/// can fail after a successful projection when a re-entrant Runtime mutation
|
|
/// revises the FIFO head, and the same immutable receipt may then be retried.
|
|
/// </summary>
|
|
public interface IRuntimePlacementProjectionSink
|
|
{
|
|
bool TryApply(in RuntimePlacementProjectionSnapshot projection);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shared graphical/no-window subscription which projects only the oldest
|
|
/// canonical receipt and acknowledges it only after the host sink succeeds.
|
|
/// Runtime remains the sole position, collision, residence, and lifetime
|
|
/// authority; this class owns only its observer subscription and one
|
|
/// idempotency token for a projection that succeeded before acknowledgement.
|
|
/// </summary>
|
|
public sealed class RuntimePlacementProjectionSubscription
|
|
: IRuntimePlacementObserver,
|
|
IDisposable
|
|
{
|
|
private readonly RuntimePlacementProjectionChannel _channel;
|
|
private readonly Func<RuntimeGenerationToken> _generation;
|
|
private readonly IRuntimePlacementProjectionSink _sink;
|
|
private IDisposable? _subscription;
|
|
private RuntimePlacementProjectionToken _appliedAwaitingAcknowledgement;
|
|
private bool _disposed;
|
|
|
|
public RuntimePlacementProjectionSubscription(
|
|
GameRuntime runtime,
|
|
IRuntimePlacementProjectionSink sink)
|
|
: this(runtime, sink, retryPendingOnSubscribe: true)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Subscribes before optionally draining the pending FIFO. A session route
|
|
/// which must publish its own disposal/retry ownership first passes
|
|
/// <c>false</c>, stores those owners, then calls <see cref="RetryPending"/>.
|
|
/// </summary>
|
|
public RuntimePlacementProjectionSubscription(
|
|
GameRuntime runtime,
|
|
IRuntimePlacementProjectionSink sink,
|
|
bool retryPendingOnSubscribe)
|
|
: this(
|
|
runtime?.Placements
|
|
?? throw new ArgumentNullException(nameof(runtime)),
|
|
() => runtime.Generation,
|
|
sink,
|
|
retryPendingOnSubscribe)
|
|
{
|
|
}
|
|
|
|
internal RuntimePlacementProjectionSubscription(
|
|
RuntimePlacementProjectionChannel channel,
|
|
Func<RuntimeGenerationToken> generation,
|
|
IRuntimePlacementProjectionSink sink)
|
|
: this(
|
|
channel,
|
|
generation,
|
|
sink,
|
|
retryPendingOnSubscribe: true)
|
|
{
|
|
}
|
|
|
|
internal RuntimePlacementProjectionSubscription(
|
|
RuntimePlacementProjectionChannel channel,
|
|
Func<RuntimeGenerationToken> generation,
|
|
IRuntimePlacementProjectionSink sink,
|
|
bool retryPendingOnSubscribe)
|
|
{
|
|
_channel = channel ?? throw new ArgumentNullException(nameof(channel));
|
|
_generation = generation
|
|
?? throw new ArgumentNullException(nameof(generation));
|
|
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
|
|
_subscription = _channel.Subscribe(this);
|
|
if (retryPendingOnSubscribe)
|
|
_ = RetryPending();
|
|
}
|
|
|
|
public bool HasAppliedReceiptAwaitingAcknowledgement =>
|
|
_appliedAwaitingAcknowledgement.IsValid;
|
|
|
|
/// <summary>
|
|
/// C2-2 review fix (delta round, B5(b)): true when Runtime's placement
|
|
/// FIFO has at least one outstanding receipt, so a host can early-out
|
|
/// before calling <see cref="RetryPending"/> without reaching the
|
|
/// Runtime placement channel directly — the architectural boundary
|
|
/// <c>RuntimePhysicsOwnershipTests.ProductionHostsUseSharedPlacementSubscriptionWithoutDirectChannel</c>
|
|
/// enforces (hosts consume placement state ONLY through this
|
|
/// subscription). Only closes the EMPTY-FIFO case: when the count is
|
|
/// nonzero, <c>RetryPending</c> still reaches
|
|
/// <c>RuntimeSetPositionState.RetryPendingProjections</c>'s per-call
|
|
/// array snapshot — see docs/ISSUES.md for that residual.
|
|
/// </summary>
|
|
public bool HasPendingReceipts => _channel.PendingCount != 0;
|
|
|
|
/// <summary>
|
|
/// Republishes Runtime's complete still-pending FIFO. Later receipts are
|
|
/// ignored until the exact oldest receipt projects and acknowledges.
|
|
/// </summary>
|
|
public bool RetryPending()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
RuntimeGenerationToken generation = _generation();
|
|
if (_appliedAwaitingAcknowledgement.IsValid
|
|
&& (!_channel.TryPeek(
|
|
generation,
|
|
out RuntimePlacementProjectionSnapshot head)
|
|
|| head.Token != _appliedAwaitingAcknowledgement))
|
|
{
|
|
_appliedAwaitingAcknowledgement = default;
|
|
}
|
|
return _channel.RetryPending(generation);
|
|
}
|
|
|
|
public void OnPlacement(in RuntimePlacementDelta delta)
|
|
{
|
|
if (_disposed
|
|
|| !_channel.TryPeek(
|
|
delta.Stamp.Generation,
|
|
out RuntimePlacementProjectionSnapshot head)
|
|
|| head != delta.Placement)
|
|
{
|
|
return;
|
|
}
|
|
|
|
RuntimePlacementProjectionToken token = head.Token;
|
|
if (_appliedAwaitingAcknowledgement != token)
|
|
{
|
|
if (!_sink.TryApply(in head))
|
|
return;
|
|
// A sink can synchronously tear down its host while applying a
|
|
// receipt. Leave that receipt pending for the replacement host;
|
|
// disposal is never permission to acknowledge afterward.
|
|
if (_disposed)
|
|
return;
|
|
_appliedAwaitingAcknowledgement = token;
|
|
}
|
|
|
|
if (_channel.Acknowledge(delta.Stamp.Generation, token)
|
|
&& _appliedAwaitingAcknowledgement == token)
|
|
{
|
|
_appliedAwaitingAcknowledgement = default;
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_disposed = true;
|
|
Interlocked.Exchange(ref _subscription, null)?.Dispose();
|
|
_appliedAwaitingAcknowledgement = default;
|
|
}
|
|
}
|