acdream/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs
Erik cd3129e9d6 fix(physics): C4 route 7 — child cell propagation moves from a render tick into Runtime
Retail re-cells children when their parent crosses a cell, recursively, to
unbounded depth. acdream did it from a RENDER tick, so headless parented
children were cell-less forever and the canonical cell had two writers. This
slice makes Runtime the sole authority and demotes App's tick to
presentation-only. Contract:
docs/research/2026-08-04-c4-route-7-contract.md; the research that unblocked
it is docs/research/2026-08-04-retail-parent-cell-propagation.md (ca96ea5e).

Retail: SetPositionInternal @0x00515330 branches on `this->cell == curr_cell`
@0x0051536d; the changed branch reaches change_cell @0x00513390, whose
delegates leave_cell @0x00510f50 and enter_cell @0x00510ed0 self-recurse over
children and write the FULL identity (add_object @0x00510ee2, objcell_id
@0x00510f1e, part-array cell id @0x00510f2b, cell pointer @0x00510f35).
change_cell itself has no child loop.

THE TRAP, recorded because it nearly shipped: the depth-1 loop
@0x0051539c-0x005153d8 is the SAME-CELL fast path (objcell_id and part-array
id only, deliberately not the cell pointer), NOT the propagation. An
implementer who finds it first concludes "depth-1, id-only" and strands every
equipped item at a landblock boundary — the #184 class. The clincher against
that reading: update_object @0x00515d10 early-returns on `parent != 0`
@0x00515d40, so a child never runs its own physics tick and parent
propagation is the ONLY mechanism maintaining its cell.

Route 7 performs NO placement (DoPickupEvent @0x00452240 = unset_parent +
leave_world; DoParentEvent @0x00452290 = set_parent + SetPlacementFrame), so
it arms ConstrainTo nowhere — the leash rule INVERTS relative to routes
2/4/5, and both reviewers confirmed nothing arms.

Propagation is an ITERATIVE WORKLIST, not recursion. The first implementation
recursed with a depth-64 cap; both reviews independently found the cap left a
truncated tail at a stale NON-ZERO cell — permanently unrecoverable, logged
only under a probe flag, and on the withdraw path exactly the #184 shape
AP-142 clause (a) exists to reject. Shipping a fresh #184 instance inside the
slice that fixes stranded children was not acceptable, so the cap was removed
rather than tuned. The worklist retires the cap, the constant, its register
clause, and the failure mode together. Termination: every record on the stack
is already at the target pair, so nothing can be pushed twice and a hostile
A->B->A cycle collapses without a visited set.

The child write deliberately bypasses the public RuntimeEntityDirectory
.SetFullCell and calls the record method directly. This is LOAD-BEARING:
the public method re-enters PropagateFullCellToChildren, which opens with
_propagationWorklist.Clear() — routing children through it mid-drain would
wipe the shared stack and silently drop every unprocessed sibling. Any future
side effect added to the public SetFullCell must be mirrored by hand at that
call site.

Deliberate divergence, recorded not disguised: retail's removal path leaves
children with a null cell pointer but a STALE nonzero objcell_id @0x005133c1.
acdream does not reproduce it, because FullCellId != 0 is the liveness
predicate at 45+ sites — faithful porting would mark dead children live.
AP-142 records this; clause (d) records that acdream cannot gate propagation
on HasPartArray the way enter_cell gates on part_array @0x00510ed8, because
the flag's only writers are graphical and headless never sets it — the reason
is Slice J LAYERING, not a semantic difference (retail's part_array is itself
a mesh-construction product, single assignment site makeAnimObject
@0x0050e930 -> CPartArray::CreateSetup @0x0050e93e).

D7 adopts retail's unset_parent-before-leave_world order @0x0045227f ->
@0x00452286, applied to BOTH pickup paths including the dormant executor
replay. Its inertness was verified by reverting it and finding all 12
propagation tests still green — reported honestly rather than papered over
with a manufactured test, and independently confirmed by both reviewers.

ClassifyLeaveWorld and its request/cause types are DELETED: retail has no
classification here, and method-per-cause IS the retail dispatch shape.
Wiring it would have forced a vacuous teleport-sequence predicate with the
#307 shape.

Two review rounds plus a coordinator-required third pass; 5 MAJORs. One was a
handoff failure worth recording: enter_cell's part_array guard was correctly
identified as load-bearing by the research, dropped by the contract when it
enumerated the writes, and inherited as an omission by the code — a right
finding that evaporated across two handoffs with nobody re-reading the source.
Another was a test that survived deleting the entire behaviour it claimed to
pin, because its assertion read a field written unconditionally one line
earlier.

NoProjection is structurally unreachable from TickChild (TryResolveExactAttachment
performs a strictly stronger form of the same guard one call earlier). Kept as
a fail-safe, unit-tested directly, and documented in two places rather than
wrapped in a fabricated end-to-end test.

Headless regression test — the direct gate for this defect, which FAILED
before this work because no code path existed:
RuntimeLiveEntitySessionControllerTests
.DirectSink_D5_StandaloneParentEventCommitsChildToParentsExactCell.

Probe: ACDREAM_PROBE_CHILD_CELL=1 emits [child-cell] lines at all four write
sites (attach / headless-attach / propagate / withdraw / delete). TEMPORARY.

Complete Release suite MEASURED at 11,079 passed / 4 skipped / 0 failed
(baseline 11,063 at cff52c44, +16). An allocation flake appeared once under
load and was proven NOT this slice by reachability — RuntimeCollisionReportingState
contains zero SetFullCell and zero ParentAttachments references.

STILL OWED: the two-client connected gate (equip/unequip, carry across
landblock boundaries, pickup, loot, reconnect) with ACDREAM_PROBE_CHILD_CELL=1,
and a session counts only if [child-cell] cause=propagate lines appear.

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

850 lines
32 KiB
C#

using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Entities;
/// <summary>
/// The single update-thread authority for live server GUIDs, incarnations,
/// accepted wire snapshots, teardown tombstones, and runtime-local identity.
/// It owns no graphical state and can run without loading App or a backend.
/// </summary>
public sealed class RuntimeEntityDirectory
{
public const uint FirstLocalEntityId = 1_000_000u;
public const uint LastLocalEntityId = 0x3FFF_FFFFu;
private readonly InboundPhysicsStateController _inbound = new();
private readonly Dictionary<uint, RuntimeEntityRecord> _activeByGuid = new();
private readonly Dictionary<(uint Guid, ushort Incarnation), RuntimeEntityRecord>
_teardownByIncarnation = new();
private readonly Dictionary<uint, RuntimeEntityRecord> _byLocalId = new();
private readonly Dictionary<uint, ulong> _lifetimeMutationByGuid = new();
private uint _nextLocalEntityId;
public RuntimeEntityDirectory(uint firstLocalEntityId = FirstLocalEntityId)
{
if (firstLocalEntityId is < FirstLocalEntityId or > LastLocalEntityId)
{
throw new ArgumentOutOfRangeException(
nameof(firstLocalEntityId),
$"Live entity ids must stay in 0x{FirstLocalEntityId:X8}..0x{LastLocalEntityId:X8}.");
}
_nextLocalEntityId = firstLocalEntityId;
}
public int Count => _activeByGuid.Count;
public int PendingTeardownCount => _teardownByIncarnation.Count;
public int ClaimedLocalIdCount => _byLocalId.Count;
public ulong SessionLifetimeVersion { get; private set; }
public IReadOnlyCollection<RuntimeEntityRecord> ActiveRecords => _activeByGuid.Values;
public IReadOnlyCollection<RuntimeEntityRecord> TeardownRecords =>
_teardownByIncarnation.Values;
public IReadOnlyDictionary<uint, WorldSession.EntitySpawn> Snapshots => _inbound.Snapshots;
public ParentAttachmentState ParentAttachments { get; } = new();
public InboundCreateResult AcceptCreate(WorldSession.EntitySpawn incoming) =>
_inbound.AcceptCreate(incoming);
internal InboundCreateResult AcceptCreateDeferredSameGeneration(
WorldSession.EntitySpawn incoming) =>
_inbound.AcceptCreateDeferredSameGeneration(incoming);
public CreateObjectTimestampDisposition PreviewCreateDisposition(
WorldSession.EntitySpawn incoming) =>
_inbound.PreviewCreateDisposition(incoming);
public bool TryDelete(
AcDream.Core.Net.Messages.DeleteObject.Parsed delete,
bool isLocalPlayer) =>
_inbound.TryDelete(delete, isLocalPlayer);
public bool TryGetSnapshot(uint guid, out WorldSession.EntitySpawn spawn) =>
_inbound.TryGetSnapshot(guid, out spawn);
internal bool TryGetAcceptedTimestamps(
uint guid,
out AcceptedPhysicsTimestamps timestamps) =>
_inbound.TryGetAcceptedTimestamps(guid, out timestamps);
public bool TryGetActive(uint guid, out RuntimeEntityRecord record) =>
_activeByGuid.TryGetValue(guid, out record!);
public bool IsCurrent(RuntimeEntityRecord record) =>
_activeByGuid.TryGetValue(record.ServerGuid, out RuntimeEntityRecord? current)
&& ReferenceEquals(current, record);
public bool TryGetByLocalId(uint localEntityId, out RuntimeEntityRecord record) =>
_byLocalId.TryGetValue(localEntityId, out record!);
public bool TryGetTeardown(
uint guid,
ushort incarnation,
out RuntimeEntityRecord record) =>
_teardownByIncarnation.TryGetValue((guid, incarnation), out record!);
public RuntimeEntityRecord AddActive(WorldSession.EntitySpawn snapshot)
{
if (_activeByGuid.ContainsKey(snapshot.Guid))
{
throw new InvalidOperationException(
$"Live entity 0x{snapshot.Guid:X8} already has an active incarnation.");
}
var record = new RuntimeEntityRecord(snapshot);
_activeByGuid.Add(snapshot.Guid, record);
try
{
ClaimLocalId(record);
return record;
}
catch
{
_activeByGuid.Remove(snapshot.Guid);
throw;
}
}
public bool RemoveActive(uint guid, out RuntimeEntityRecord? record) =>
_activeByGuid.Remove(guid, out record);
public bool RemoveActive(RuntimeEntityRecord expected)
{
if (!IsCurrent(expected))
return false;
return _activeByGuid.Remove(expected.ServerGuid);
}
public void RetainTeardown(RuntimeEntityRecord record)
{
var key = (record.ServerGuid, record.Incarnation);
if (_teardownByIncarnation.TryGetValue(
key,
out RuntimeEntityRecord? retained)
&& !ReferenceEquals(retained, record))
{
throw new InvalidOperationException(
$"Live entity teardown tombstone collision for 0x{record.ServerGuid:X8} generation {record.Incarnation}.");
}
_teardownByIncarnation[key] = record;
}
public void ReleaseTeardown(RuntimeEntityRecord record)
{
var key = (record.ServerGuid, record.Incarnation);
if (_teardownByIncarnation.TryGetValue(
key,
out RuntimeEntityRecord? retained)
&& ReferenceEquals(retained, record))
{
_teardownByIncarnation.Remove(key);
}
}
public bool HasPendingTeardown(uint serverGuid)
{
foreach ((uint Guid, ushort Incarnation) key in _teardownByIncarnation.Keys)
{
if (key.Guid == serverGuid)
return true;
}
return false;
}
public uint ClaimLocalId(RuntimeEntityRecord record)
{
if (!IsKnown(record))
{
throw new InvalidOperationException(
"A local id can only be claimed for an active or retained incarnation.");
}
if (record.LocalEntityId is { } existing)
return existing;
uint start = _nextLocalEntityId;
do
{
uint candidate = _nextLocalEntityId;
_nextLocalEntityId = candidate == LastLocalEntityId
? FirstLocalEntityId
: candidate + 1u;
if (_byLocalId.ContainsKey(candidate))
continue;
_byLocalId.Add(candidate, record);
record.LocalEntityId = candidate;
return candidate;
}
while (_nextLocalEntityId != start);
throw new InvalidOperationException("The live entity id namespace is exhausted.");
}
public bool ReleaseLocalId(RuntimeEntityRecord record)
{
if (record.LocalEntityId is not { } localId)
return false;
if (_byLocalId.TryGetValue(localId, out RuntimeEntityRecord? retained)
&& ReferenceEquals(retained, record))
{
_byLocalId.Remove(localId);
}
record.LocalEntityId = null;
return true;
}
public ulong AdvanceLifetimeMutation(uint serverGuid)
{
ulong next = _lifetimeMutationByGuid.GetValueOrDefault(serverGuid) + 1UL;
_lifetimeMutationByGuid[serverGuid] = next;
return next;
}
public ulong CurrentLifetimeMutation(uint serverGuid) =>
_lifetimeMutationByGuid.GetValueOrDefault(serverGuid);
public void BeginSessionClear()
{
SessionLifetimeVersion++;
_lifetimeMutationByGuid.Clear();
ParentAttachments.Clear();
_inbound.Clear();
}
public bool CompleteSessionClearIfConverged()
{
if (_activeByGuid.Count != 0 || _teardownByIncarnation.Count != 0)
return false;
_byLocalId.Clear();
ParentAttachments.Clear();
_inbound.Clear();
return true;
}
public void RefreshSnapshot(
RuntimeEntityRecord record,
WorldSession.EntitySpawn accepted,
bool refreshPosition = false)
{
EnsureKnown(record);
uint previousCell = record.FullCellId;
record.Snapshot = accepted;
record.RefreshDerivedState(refreshPosition);
if (record.FullCellId != previousCell)
{
PropagateFullCellToChildren(
record,
record.FullCellId,
record.CanonicalLandblockId);
}
}
public void AdvanceCreateAuthority(RuntimeEntityRecord record)
{
EnsureKnown(record);
record.AdvanceCreateAuthority();
}
public void AdvancePositionAuthority(RuntimeEntityRecord record)
{
EnsureKnown(record);
record.AdvancePositionAuthority();
}
public void AdvanceVectorAuthority(RuntimeEntityRecord record)
{
EnsureKnown(record);
record.AdvanceVectorAuthority();
}
public void AdvanceMovementAuthority(RuntimeEntityRecord record)
{
EnsureKnown(record);
record.AdvanceMovementAuthority();
}
public void AdvanceMovementCommit(RuntimeEntityRecord record)
{
EnsureKnown(record);
record.AdvanceMovementCommit();
}
public void AdvancePlacementCommit(RuntimeEntityRecord record)
{
EnsureKnown(record);
record.AdvancePlacementCommit();
}
public void AdvanceParentCommit(RuntimeEntityRecord record)
{
EnsureKnown(record);
record.AdvanceParentCommit();
}
public void AdvanceObjDescAuthority(RuntimeEntityRecord record)
{
EnsureKnown(record);
record.AdvanceObjDescAuthority();
}
public RetailPhysicsStateTransition ApplyRawPhysicsState(
RuntimeEntityRecord record,
uint rawState)
{
EnsureKnown(record);
return record.ApplyRawPhysicsState(rawState);
}
public bool TryDequeueStateTransition(
RuntimeEntityRecord record,
out RetailPhysicsStateTransition transition)
{
EnsureKnown(record);
return record.TryDequeueStateTransition(out transition);
}
public void SetChildNoDraw(RuntimeEntityRecord record, bool noDraw)
{
EnsureKnown(record);
record.SetChildNoDraw(noDraw);
}
public void SuspendObjectClock(RuntimeEntityRecord record)
{
EnsureKnown(record);
record.SuspendObjectClock();
}
/// <summary>
/// Re-activates a clock suspended by <see cref="SuspendObjectClock"/>, for
/// rolling back a withdrawal that is being cancelled rather than
/// completed. NOT an exact inverse — the retained sub-quantum time is
/// discarded, which is retail's <c>set_active(1)</c> rebase semantic; see
/// <see cref="RuntimeEntityRecord.ResumeObjectClock"/>. Distinct from
/// <see cref="ResetObjectClockForEnterWorld"/>, which is the
/// entering-the-world edge and additionally rebases the static/dynamic
/// quantum shape; a rollback re-activates a clock that never conceptually
/// left.
/// </summary>
public void ResumeObjectClock(RuntimeEntityRecord record)
{
EnsureKnown(record);
record.ResumeObjectClock();
}
public void ResetObjectClockForEnterWorld(RuntimeEntityRecord record, bool isStatic)
{
EnsureKnown(record);
record.ResetObjectClockForEnterWorld(isStatic);
}
public void SetFullCell(
RuntimeEntityRecord record,
uint fullCellId,
uint canonicalLandblockId)
{
EnsureKnown(record);
record.SetFullCell(fullCellId, canonicalLandblockId);
PropagateFullCellToChildren(record, fullCellId, canonicalLandblockId);
}
/// <summary>
/// Reused across every <see cref="PropagateFullCellToChildren"/> call.
/// Always empty on entry and on exit (the loop drains it unconditionally
/// before returning) — see that method's remarks for why sharing it is
/// safe rather than a repeat of the round-3 A6/B4 scratch-buffer
/// reentrancy lesson: this stack never escapes the method, and nothing
/// on the write path can trigger a nested call while it holds state.
/// </summary>
private readonly Stack<RuntimeEntityRecord> _propagationWorklist = new();
/// <summary>
/// C4 route 7 D2 — the sole propagation hook. Retail's parent-cell-
/// crossing propagation (<c>CPhysicsObj::SetPositionInternal</c>
/// @0x00515330's changed-cell branch @0x00515372 -&gt; <c>change_cell</c>
/// @0x00513390 -&gt; the self-recursive <c>enter_cell</c>/<c>leave_cell</c>
/// @0x00510ed0/@0x00510f50) has exactly one acdream analogue: every
/// canonical cell write already funnels through this method or
/// <see cref="RefreshSnapshot"/>'s derived-state write (see
/// docs/research/2026-08-04-retail-parent-cell-propagation.md and the
/// route-7 contract's D2).
///
/// <para>
/// ITERATIVE, not recursive (round-3 remediation — both the retail and
/// architecture reviews independently converged on the same finding,
/// N4/B3: a depth-capped RECURSIVE version left a subtree beyond the cap
/// at a STALE NONZERO cell permanently — on the withdraw path that is
/// the #184 shape verbatim, the exact defect AP-142 clause (a) exists to
/// reject, shipped fresh inside the slice whose headline is fixing it.
/// A reused <see cref="_propagationWorklist"/> stack removes the depth
/// concept entirely rather than mitigating it: there is no C# call-stack
/// growth to bound, so the only limit is the number of committed
/// relations actually in the system (which cannot exceed the live
/// entity count) — matching retail's own genuinely unbounded recursion
/// exactly, with no acdream-only cap and no register row for one. This
/// also retires B5 in the sense that there is no longer a RECURSIVE call
/// to reason about — but the bypass itself is NOT retired: the loop
/// below still calls a child's own <c>SetFullCell</c> directly rather
/// than the public <see cref="SetFullCell"/>, and that bypass is
/// DELIBERATE and LOAD-BEARING, not incidental. The public
/// <see cref="SetFullCell"/> calls this method, and this method opens
/// with <c>_propagationWorklist.Clear()</c> — routing a child through
/// the public method would re-enter this method mid-drain, clear the
/// shared worklist out from under the OUTER loop, and silently drop
/// every sibling still waiting on the stack, with no error and no
/// exception. Any side effect added to the public
/// <see cref="SetFullCell"/> in the future MUST be mirrored by hand at
/// this call site, because this call site cannot route through it.
/// </para>
///
/// <para>
/// Skipping a child whose <see cref="RuntimeEntityRecord.FullCellId"/>
/// (and <see cref="RuntimeEntityRecord.CanonicalLandblockId"/>, A7)
/// already equals the target is BOTH the cycle guard (an A→B→A wire-
/// induced relation cycle writes A, pushes B, writes B, pops B, finds A
/// already at the target value, and does not re-push it — no visited
/// set needed) and the retail-behavioral subsumption of retail's
/// separate same-cell depth-1 id refresh (@0x0051539c-@0x005153d8)
/// under this project's single-field cell model (AP-142). Zero
/// allocation after warmup:
/// <see cref="ParentAttachmentState.ChildrenAttachedToParent"/> returns
/// the stored list or <see cref="Array.Empty{T}"/>, and the worklist
/// stack is a reused field, never a fresh collection per call. Field
/// writes only — no clock, workset, shadow, or placement work — so this
/// is safe to run re-entrantly inside whatever transaction is mid-commit
/// on the parent's own cell; the worklist field itself is safe to share
/// across calls because the loop below unconditionally drains it to
/// empty before this method returns, and nothing on the write path
/// (a field assignment and an optional diagnostic log line) can call
/// back into a nested <see cref="SetFullCell"/>. NOT gated on retail's
/// <c>part_array != 0</c> guard (@0x00510ed8) — see AP-142 clause (d)
/// for why that guard has no reproducible analogue at acdream's
/// canonical layer.
///
/// <para>
/// P8 (contract proof obligation, corrected at the architecture review
/// round — A3): this step deliberately publishes NO per-child
/// <c>RuntimeEntityChange.Rebucketed</c> delta, matching
/// <c>RuntimePhysicsState.CommitCanonicalCell</c>'s precedent. The
/// correct basis for that decision is BOTH observer interfaces, not
/// just <c>IRuntimeEntityObjectObserver</c>'s direct implementations:
/// <c>GameRuntimeEventHub</c> itself implements
/// <c>IRuntimeEntityObjectObserver</c> and fans every entity delta out
/// to <c>IRuntimeEventObserver</c>, and <c>RuntimeTraceRecorder.OnEntity</c>
/// (<c>GameRuntimeEvents.cs</c>) is a non-stub shipped consumer that
/// records <c>(delta.Change, delta.Entity.CellId)</c> for every entity
/// delta — it is diagnostic tracing, not gameplay logic, and the
/// entries it would lose are graphical-only equipped-child
/// <c>Rebucketed</c> deltas that TickChild's demoted rebucket used to
/// produce, so silence here is acceptable, but "no consumer at all" is
/// false and must not be restated that way.
/// </para>
///
/// <para>
/// P4 (contract proof obligation — the child broadphase story, stated):
/// this step writes canonical cell FIELDS only. At attach, the
/// preceding cell-less edge already force-ends collision reporting
/// (<c>Physics.CollisionReports.LeaveWorld</c>) and no child broadphase
/// registration exists to rebuild — retail's own
/// <c>recalc_cross_cells</c> @0x00515A30 runs at attach only, never
/// per-crossing (the §0 trap this hook deliberately does not port). At
/// every crossing thereafter, a committed child never becomes a spatial
/// root (<c>LiveEntityRuntime.HasSpatialRuntimeProjection</c> keys off
/// <c>ProjectionKind is World</c>, and an attached child is always
/// <c>ProjectionKind.Attached</c> — <c>AcknowledgeSpatialProjection</c>
/// is never called on this path, headless or graphical), so it never
/// joins any physics workset or shadow/cross-cell list this step would
/// need to maintain. Confirmed, not assumed: zero shadow work is
/// performed anywhere in this method.
/// </para>
/// </summary>
private void PropagateFullCellToChildren(
RuntimeEntityRecord root,
uint fullCellId,
uint canonicalLandblockId)
{
_propagationWorklist.Clear();
_propagationWorklist.Push(root);
while (_propagationWorklist.Count > 0)
{
RuntimeEntityRecord current = _propagationWorklist.Pop();
IReadOnlyList<uint> children = ParentAttachments.ChildrenAttachedToParent(
current.ServerGuid,
current.Incarnation);
for (int i = 0; i < children.Count; i++)
{
// A7 (architecture review, LOW): key the idempotence/cycle
// skip on the PAIR, not FullCellId alone. Every production
// writer derives canonicalLandblockId from fullCellId, so
// the two are coupled today, but the coupling is not
// enforced anywhere (LiveEntityRuntime.CanonicalLandblockId's
// setter can write a same-cell, different-landblock pair) —
// testing the pair is the version that is correct
// independent of that coupling.
if (!TryGetActive(children[i], out RuntimeEntityRecord child)
|| (child.FullCellId == fullCellId
&& child.CanonicalLandblockId == canonicalLandblockId))
{
continue;
}
if (PhysicsDiagnostics.ProbeChildCellEnabled)
{
Console.WriteLine(FormattableString.Invariant(
$"[child-cell] parent=0x{current.ServerGuid:X8} child=0x{child.ServerGuid:X8} old=0x{child.FullCellId:X8} new=0x{fullCellId:X8} cause={(fullCellId == 0u ? "withdraw" : "propagate")}"));
}
child.SetFullCell(fullCellId, canonicalLandblockId);
_propagationWorklist.Push(child);
}
}
}
public void SetFinalPhysicsState(
RuntimeEntityRecord record,
PhysicsStateFlags state)
{
EnsureKnown(record);
record.SetFinalPhysicsState(state);
}
internal bool StopMissileAfterCollision(
RuntimeEntityRecord record,
bool requireCurrentMissile)
{
EnsureKnown(record);
return record.StopMissileAfterCollision(requireCurrentMissile);
}
public void SetHasPartArray(RuntimeEntityRecord record, bool value)
{
EnsureKnown(record);
record.HasPartArray = value;
}
public void SetPhysicsBody(
RuntimeEntityRecord record,
AcDream.Core.Physics.PhysicsBody? body)
{
EnsureKnown(record);
record.SetPhysicsBody(body);
}
public void SetPhysicsBodyAcquisitionInProgress(
RuntimeEntityRecord record,
bool value)
{
EnsureKnown(record);
record.PhysicsBodyAcquisitionInProgress = value;
}
public void SetRemoteMotion(
RuntimeEntityRecord record,
IRuntimeRemoteMotion? remote)
{
EnsureKnown(record);
record.RemoteMotion = remote;
}
public void SetRemoteMotionBindingInProgress(
RuntimeEntityRecord record,
bool value)
{
EnsureKnown(record);
record.RemoteMotionBindingInProgress = value;
}
public void SetProjectile(
RuntimeEntityRecord record,
IRuntimeProjectile? projectile)
{
EnsureKnown(record);
record.Projectile = projectile;
}
public void SetProjectileBindingInProgress(
RuntimeEntityRecord record,
bool value)
{
EnsureKnown(record);
record.ProjectileBindingInProgress = value;
}
public void SetRequiresRemotePlacementRuntime(
RuntimeEntityRecord record,
bool value)
{
EnsureKnown(record);
record.RequiresRemotePlacementRuntime = value;
}
public void SetPhysicsHost(
RuntimeEntityRecord record,
AcDream.Core.Physics.Motion.IPhysicsObjHost? host)
{
EnsureKnown(record);
record.PhysicsHost = host;
}
public void SetDeleteAcceptedForTeardown(
RuntimeEntityRecord record,
bool value)
{
EnsureKnown(record);
record.DeleteAcceptedForTeardown = value;
}
public bool TryApplyObjDesc(
AcDream.Core.Net.Messages.ObjDescEvent.Parsed update,
out WorldSession.EntitySpawn accepted) =>
_inbound.TryApplyObjDesc(update, out accepted);
public bool TryApplyPickup(
AcDream.Core.Net.Messages.PickupEvent.Parsed update,
out WorldSession.EntitySpawn accepted) =>
_inbound.TryApplyPickup(update, out accepted);
public bool TryApplyCreateParent(
CreateParentUpdate update,
out WorldSession.EntitySpawn accepted) =>
_inbound.TryApplyCreateParent(update, out accepted);
public bool TryApplyParent(
AcDream.Core.Net.Messages.ParentEvent.Parsed update,
out WorldSession.EntitySpawn accepted) =>
_inbound.TryApplyParent(update, out accepted);
public bool TryCommitParent(
uint childGuid,
uint parentGuid,
uint parentLocation,
uint placementId,
ushort positionSequence,
out WorldSession.EntitySpawn accepted) =>
_inbound.TryCommitParent(
childGuid,
parentGuid,
parentLocation,
placementId,
positionSequence,
out accepted);
public bool TryApplyMotion(
WorldSession.EntityMotionUpdate update,
bool retainPayload,
out WorldSession.EntitySpawn accepted,
out AcceptedPhysicsTimestamps timestamps) =>
_inbound.TryApplyMotion(update, retainPayload, out accepted, out timestamps);
public bool TryApplyVector(
AcDream.Core.Net.Messages.VectorUpdate.Parsed update,
out WorldSession.EntitySpawn accepted) =>
_inbound.TryApplyVector(update, out accepted);
public bool TryApplyState(
AcDream.Core.Net.Messages.SetState.Parsed update,
out WorldSession.EntitySpawn accepted) =>
_inbound.TryApplyState(update, out accepted);
public bool TryApplyPosition(
WorldSession.EntityPositionUpdate update,
bool isLocalPlayer,
System.Numerics.Quaternion? forcePositionRotation,
System.Numerics.Vector3? currentLocalVelocity,
out PositionTimestampDisposition disposition,
out WorldSession.EntitySpawn accepted,
out AcceptedPhysicsTimestamps timestamps) =>
_inbound.TryApplyPosition(
update,
isLocalPlayer,
forcePositionRotation,
currentLocalVelocity,
out disposition,
out accepted,
out timestamps);
internal bool TryAcceptDeferredPosition(
WorldSession.EntityPositionUpdate update,
bool isLocalPlayer,
out PositionTimestampDisposition disposition,
out AcceptedPhysicsTimestamps timestamps,
out bool hasTimestampMutation) =>
_inbound.TryAcceptDeferredPosition(
update,
isLocalPlayer,
out disposition,
out timestamps,
out hasTimestampMutation);
internal bool TryAcceptDeferredObjDesc(
ObjDescEvent.Parsed update,
out AcceptedPhysicsTimestamps timestamps) =>
_inbound.TryAcceptDeferredObjDesc(update, out timestamps);
internal bool TryAcceptDeferredPickup(
PickupEvent.Parsed update,
out AcceptedPhysicsTimestamps timestamps) =>
_inbound.TryAcceptDeferredPickup(update, out timestamps);
internal bool TryAcceptDeferredCreateParent(
CreateParentUpdate update,
out AcceptedPhysicsTimestamps timestamps) =>
_inbound.TryAcceptDeferredCreateParent(update, out timestamps);
internal bool TryAcceptDeferredParent(
ParentEvent.Parsed update,
out AcceptedPhysicsTimestamps timestamps) =>
_inbound.TryAcceptDeferredParent(update, out timestamps);
internal bool TryAcceptDeferredMotion(
WorldSession.EntityMotionUpdate update,
out AcceptedPhysicsTimestamps timestamps,
out bool hasTimestampMutation) =>
_inbound.TryAcceptDeferredMotion(
update,
out timestamps,
out hasTimestampMutation);
internal bool TryAcceptDeferredState(
SetState.Parsed update,
out AcceptedPhysicsTimestamps timestamps) =>
_inbound.TryAcceptDeferredState(update, out timestamps);
internal bool TryAcceptDeferredVector(
VectorUpdate.Parsed update,
out AcceptedPhysicsTimestamps timestamps) =>
_inbound.TryAcceptDeferredVector(update, out timestamps);
public bool IsFreshTeleportStart(uint guid, ushort teleportSequence) =>
_inbound.IsFreshTeleportStart(guid, teleportSequence);
// Round 3 A1: gate-less instance seams for the initial-Create
// continuation executor. Each merges against _inbound's OWN
// _snapshots[guid] (never a caller-supplied base) and writes the result
// back, keeping this store and RuntimeEntityRecord.Snapshot in lockstep.
internal bool ApplyAcceptedObjDescSnapshot(
uint guid,
ObjDescEvent.Parsed update,
out WorldSession.EntitySpawn accepted) =>
_inbound.ApplyAcceptedObjDescSnapshot(guid, update, out accepted);
internal bool ApplyAcceptedPickupSnapshot(
uint guid,
PickupEvent.Parsed update,
out WorldSession.EntitySpawn accepted) =>
_inbound.ApplyAcceptedPickupSnapshot(guid, update, out accepted);
internal bool ApplyAcceptedCreateParentSnapshot(
uint guid,
CreateParentUpdate update,
out WorldSession.EntitySpawn accepted) =>
_inbound.ApplyAcceptedCreateParentSnapshot(guid, update, out accepted);
internal bool ApplyAcceptedParentSnapshot(
uint guid,
ParentEvent.Parsed update,
out WorldSession.EntitySpawn accepted) =>
_inbound.ApplyAcceptedParentSnapshot(guid, update, out accepted);
internal bool ApplyAcceptedMotionSnapshot(
uint guid,
ushort movementSequence,
ushort acceptedServerControlledMove,
WorldSession.EntityMotionUpdate update,
bool retainPayload,
out WorldSession.EntitySpawn accepted) =>
_inbound.ApplyAcceptedMotionSnapshot(
guid,
movementSequence,
acceptedServerControlledMove,
update,
retainPayload,
out accepted);
internal bool ApplyAcceptedStateSnapshot(
uint guid,
SetState.Parsed update,
out WorldSession.EntitySpawn accepted) =>
_inbound.ApplyAcceptedStateSnapshot(guid, update, out accepted);
internal bool ApplyAcceptedVectorSnapshot(
uint guid,
VectorUpdate.Parsed update,
out WorldSession.EntitySpawn accepted) =>
_inbound.ApplyAcceptedVectorSnapshot(guid, update, out accepted);
internal bool ApplyAcceptedPositionSnapshot(
uint guid,
WorldSession.EntityPositionUpdate update,
PositionTimestampDisposition disposition,
AcceptedPhysicsTimestamps timestamps,
bool isLocalPlayer,
System.Numerics.Quaternion? forcePositionRotation,
System.Numerics.Vector3? currentLocalVelocity,
bool installPlacementFrame,
bool clearParent,
out WorldSession.EntitySpawn accepted) =>
_inbound.ApplyAcceptedPositionSnapshot(
guid,
update,
disposition,
timestamps,
isLocalPlayer,
forcePositionRotation,
currentLocalVelocity,
installPlacementFrame,
clearParent,
out accepted);
internal bool ApplyAcceptedPositionExecutionRejectedSnapshot(
uint guid,
ushort acceptedPositionSequence,
AcceptedPhysicsTimestamps timestamps,
out WorldSession.EntitySpawn accepted) =>
_inbound.ApplyAcceptedPositionExecutionRejectedSnapshot(
guid,
acceptedPositionSequence,
timestamps,
out accepted);
internal bool ApplyAcceptedWeenieDescriptionSnapshot(
uint guid,
WorldSession.EntitySpawn incoming,
out WorldSession.EntitySpawn merged) =>
_inbound.ApplyAcceptedWeenieDescriptionSnapshot(guid, incoming, out merged);
/// <summary>
/// #297 review round 2: see
/// <see cref="InboundPhysicsStateController.TryRefreshObjectDescriptionFlags"/>.
/// </summary>
internal bool TryRefreshObjectDescriptionFlags(
uint guid,
uint bitfield,
out WorldSession.EntitySpawn merged) =>
_inbound.TryRefreshObjectDescriptionFlags(guid, bitfield, out merged);
private bool IsKnown(RuntimeEntityRecord record)
{
if (IsCurrent(record))
return true;
return _teardownByIncarnation.TryGetValue(
(record.ServerGuid, record.Incarnation),
out RuntimeEntityRecord? retained)
&& ReferenceEquals(retained, record);
}
private void EnsureKnown(RuntimeEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);
if (!IsKnown(record))
{
throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} is no longer owned by the directory.");
}
}
}