fix(physics): bind a parented child to the parent's live incarnation (#319)
A player-parented child never received a canonical cell. Its FullCellId stayed 0 for its whole attached lifetime, so it could not follow the player across a boundary. Scope was wider than the local player: every REMOTE player's equipment too. ROOT CAUSE. EquippedChildRenderController hardcoded ParentInstanceSequence: 0 for a parented CreateObject. Correct for creatures and statics, which really are sequence 0; wrong for players, whose ObjectInstance is Character.TotalLogins (ACE Player_Networking.cs:37). The relation filed under (playerGuid, 0) while the record carried TotalLogins, so both route-7 write sites — D1's attach re-cell and D2's propagation lookup — keyed on an incarnation that never matched. TryCommitParent did not validate the sequence, so the attach succeeded and printed normally. Silent. A ROUTE 7 REGRESSION (cd3129e9) that un-masked a latent bug: the TickChild call route 7 deleted was keyed on the child guid alone and was structurally immune to a wrong parent key. THE FIX IS TO STOP TREATING PLAYERS DIFFERENTLY, not to special-case them. Retail's attach path is guid-only end to end — PhysicsDesc::get_parent_id @0x00558a18 -> CObjectMaint::GetObjectA @0x00558a2d -> set_parent @0x00558a3e, with SetChildren @0x00509370 hash-walking by guid — and neither set_parent overload (@0x00515A90, @0x00515B50) nor enter_cell @0x00510ED0 contains any player test or instance-sequence read. Our player/non-player split was purely an artifact of keying relations by (guid, incarnation) against a wire message that carries no parent incarnation. Late-binding to whoever currently holds the guid is retail's own semantics. Fixed at BOTH producers: OnSpawn and OnCreateParentAccepted, the second carrying the byte-identical defect and not named in the contract's scope line. THE INVARIANT IS EQUALITY, NOT FRESHNESS. The contract rejected both framings I offered: every one of the 45 FullCellId liveness predicates excludes a committed child on a NON-cell clause first, so the child inherits only the parent record's existing staleness, which is already present today with no symptom. The key fix alone restores child-equals-parent for every parent class. TWO SITES GATED, inert only because the cell was zero and would have woken wrongly: the hydration candidate loop (a nonzero-cell child would take the legacy RebucketLiveEntity -> CommitRebucket, a second canonical writer — route 7's exact defect class) and RestoreShadow (would install a broadphase row for the weapon, the #184 shape, contradicting route 7's P4). Retail anchor: update_object's parent != 0 early-out @0x00515D40 — children are never independently re-placed. THREE MAJORS WERE FIXED BY DELETION. The first pass added a deferral queue for an unaddressable parent, carrying a missing child-freshness gate (A2), a sentinel-0 collision with the generation filters (A3), and unbounded accumulation (A5). Both reviewers then proved the deferred branch unreachable for BOTH producers — RegisterEntityCore defers the entire CreateObject one layer above, reading the same ?? chain, and CreateParentUpdate is produced only inside AcceptCreateCore, after that gate passes. The machinery was deleted rather than repaired, and the diff SHRANK to 76 added / 13 removed from 91/24 while gaining the A1 fix. Retail confirmed the deletion does not diverge: acdream's real port of retail's per-guid replay (QueueBlobForObject) is a different, untouched layer, and the deleted queue was a third redundant one downstream of it. THE GUARD MUST NOT TEAR WHAT IT PROTECTS. The first pass threw InvalidOperationException AFTER the canonical half had committed, so the one time it fired it left the child parented with no committed relation and a staged one blocking Resolve — a torn transaction, the exact outcome the contract pinned against. Now a pure CanCommitIncarnation precondition checked BEFORE the commit at both sites, with a logged refusal instead of a throw. Route 3's N3 principle (do not make a transient fatal on a host that must survive 30 sessions x 2 hours) reinforces it, but the tearing argument stands alone. TEST QUALITY, the recurring lesson in its most refined form. The A1 test initially passed sabotage FOR THE WRONG REASON: a mismatched ChildPositionSequence meant TryCommitParent's own gate refused in either ordering, so the three assertions carrying A1's meaning passed both ways and only an incidental staging assertion failed. It failed on stranding, not tearing. Corrected, the sabotage now names line 925 — Assert.Null(snapshot.ParentGuid), with the parent's guid in it — proving the canonical mutation happened before the catch. "Fails under sabotage" is necessary, not sufficient; WHICH assertion fails is the real question. The dual parent-class matrix (player 0x5… incarnation > 1 vs creature 0x8… incarnation 0, identical outcomes, sabotage-verified in both directions) is the structural fix for how this survived a full dual review and two connected sessions: every prior test and both captured gate logs used sequence-0 parents. Register: AP-142 clause (f); AP-132 amended to distinguish the two producers; new row AP-146 for the local player's coarse canonical cell (retail writes it per tick at SetPositionInternal @0x00515330 — which, per the retail review, ALSO walks this->children writing each child's objcell_id @0x005153AE-@0x005153D8, so retail's per-tick child propagation lives in the same function). That divergence had no row at all, a standing rule-1 violation now corrected. Follow-up #320 filed for making the player's cell track ordinary movement — deliberately excluded here: it touches the landblock-preserve contract, the Rebucketed cadence, route-2/4b-3 classification inputs AP-136/AP-138 spent four review rounds pinning, and the portal-space frozen-source-cell race. Two dual review rounds; 6 architecture MAJORs and 2 retail MAJORs closed. Diagnostic refusals are latched per child guid and the latch clears on Clear()/RemoveChild, so a recycled guid's next incarnation still logs rather than being silently suppressed. Complete Release suite MEASURED at 11,112 passed / 4 skipped / 0 failed (baseline 11,090 at52175aa1, +22). Neither known flake fired. STILL OWED: the connected gate, with the CORRECTED positive criterion — assert the equipped child's FullCellId EQUALS the parent's after a crossing (a zero is a failure, not a silence), run with BOTH a player and a creature parent, plus the new step carrying an armed creature across a landblock unload/reload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
af828a8a2a
commit
392c1e22c1
19 changed files with 3076 additions and 20 deletions
|
|
@ -70,6 +70,13 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
private readonly Func<RuntimeEntityKey, bool> _tickAttached;
|
||||
private readonly Func<RuntimeEntityKey, bool> _reconcileAttached;
|
||||
private int _activePoseCompositionVisits;
|
||||
/// <summary>
|
||||
/// #319 D3/B6 (retail + architecture review round 2, 2026-08-05):
|
||||
/// per-child log-once latch for <see cref="AcceptLateBoundCreateObjectRelation"/>'s
|
||||
/// unaddressable-parent refusal - see the identical rationale on
|
||||
/// <c>ParentAttachmentState._loggedIncarnationRefusals</c>.
|
||||
/// </summary>
|
||||
private readonly HashSet<uint> _loggedUnaddressableParentRefusals = [];
|
||||
|
||||
internal int LastFullPoseCompositionVisits { get; private set; }
|
||||
internal int LastReconcilePoseCompositionVisits { get; private set; }
|
||||
|
|
@ -126,13 +133,12 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
// 0x0051DDD0) default an absent AnimationFrame to zero.
|
||||
// Parent remains a complete relation in that case.
|
||||
uint placementId = spawn.PlacementId ?? 0u;
|
||||
Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
|
||||
AcceptLateBoundCreateObjectRelation(
|
||||
parentGuid,
|
||||
spawn.Guid,
|
||||
parentLocation,
|
||||
placementId,
|
||||
ParentInstanceSequence: 0,
|
||||
spawn.PositionSequence));
|
||||
spawn.PositionSequence);
|
||||
}
|
||||
|
||||
ResolveAndTryRealize(spawn.Guid);
|
||||
|
|
@ -796,17 +802,100 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
{
|
||||
lock (_datLock)
|
||||
{
|
||||
Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
|
||||
// CreateParentUpdate is the same-generation ObjDesc-refresh
|
||||
// shape of a CreateObject-carried relation (BuildSameGenerationEvents,
|
||||
// InboundPhysicsStateController.cs) - it carries the parent's
|
||||
// GUID and location only, never an instance sequence, for the
|
||||
// identical structural reason OnSpawn's raw CreateObject path
|
||||
// does not (#319 F1).
|
||||
AcceptLateBoundCreateObjectRelation(
|
||||
update.ParentGuid,
|
||||
update.ChildGuid,
|
||||
update.ParentLocation,
|
||||
update.PlacementId,
|
||||
ParentInstanceSequence: 0,
|
||||
update.ChildPositionSequence));
|
||||
update.ChildPositionSequence);
|
||||
ResolveAndTryRealize(update.ChildGuid);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #319 F1: late-binds a CreateObject-carried parent relation (raw
|
||||
/// CreateObject's <c>Physics.Parent</c> via <see cref="OnSpawn"/>, or
|
||||
/// the same-generation <see cref="CreateParentUpdate"/> envelope via
|
||||
/// <see cref="OnCreateParentAccepted"/>) to the parent's LIVE
|
||||
/// incarnation, rather than assuming sequence zero. Neither wire shape
|
||||
/// carries a parent instance sequence - retail's own attach is
|
||||
/// GUID-only (<c>PhysicsDesc::get_parent_id</c> @0x00558a18 ->
|
||||
/// <c>GetObjectA</c> @0x00558a2d -> <c>set_parent</c> @0x00558a3e),
|
||||
/// so "the current holder of the guid" is the faithful mapping. The
|
||||
/// parent's snapshot lookup here mirrors <see cref="ResolveRelations"/>'s
|
||||
/// own lookup (<see cref="ResolveLiveParentInstance"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// #319 A6 (architecture review, 2026-08-05): a queued/late-bind
|
||||
/// deferral for an unaddressable parent was tried here and REMOVED.
|
||||
/// <c>RuntimeEntityObjectLifetime.RegisterEntityCore</c>'s
|
||||
/// <c>EnqueueDeferredCreate</c> gate (`:797-812`) defers the ENTIRE
|
||||
/// CreateObject - both this raw shape (`incoming.ParentGuid`) and the
|
||||
/// same-generation <see cref="CreateParentUpdate"/> envelope
|
||||
/// (`incoming.Physics.Parent.Guid`, same `??` chain) - whenever its
|
||||
/// parent is not yet addressable, and does so BEFORE either wire shape
|
||||
/// is ever produced (`AcceptCreateCore`/`BuildSameGenerationEvents` are
|
||||
/// reached only after that gate passes). So this method never observes
|
||||
/// an unaddressable parent in production for EITHER producer - not an
|
||||
/// empirical absence, a structural one. A deferral queue here carried
|
||||
/// three independent defects (a missing child POSITION_TS gate, a
|
||||
/// placeholder-incarnation collision with the generation filters,
|
||||
/// unbounded mid-session accumulation) while being exercised by nothing
|
||||
/// but a test that called this class's methods directly, bypassing the
|
||||
/// production routing that makes the branch unreachable. If the else
|
||||
/// branch below is ever reached, that upstream invariant has broken;
|
||||
/// log loudly rather than reconstructing unreachable machinery to
|
||||
/// paper over it. (B2, architecture review round 2: the upstream gate
|
||||
/// tests <c>Entities.TryGetActive</c> - the active-record table - while
|
||||
/// this method's own lookup below tests <c>_liveEntities.TryGetSnapshot</c>
|
||||
/// - the inbound snapshot table. Active implies a snapshot exists,
|
||||
/// because <c>AddActive</c> is fed from the same snapshot
|
||||
/// <c>AcceptCreate</c> just wrote; the only known inversion window is
|
||||
/// inside <c>TryDeleteEntity</c>, where the snapshot is removed several
|
||||
/// statements before the active record, and reaching this method
|
||||
/// through that window would require a re-entrant child CreateObject
|
||||
/// inside it - not reachable from a single-threaded pump.) (B3: this
|
||||
/// unreachability argument is scoped to the GRAPHICAL host - the
|
||||
/// content-less direct/headless host has no
|
||||
/// <c>EquippedChildRenderController</c> at all, so it has no
|
||||
/// CreateObject-carried relation producer to which this argument would
|
||||
/// even apply.)
|
||||
/// </remarks>
|
||||
private void AcceptLateBoundCreateObjectRelation(
|
||||
uint parentGuid,
|
||||
uint childGuid,
|
||||
uint parentLocation,
|
||||
uint placementId,
|
||||
ushort childPositionSequence)
|
||||
{
|
||||
if (_liveEntities.TryGetSnapshot(parentGuid, out WorldSession.EntitySpawn parentSpawn))
|
||||
{
|
||||
Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
|
||||
parentGuid,
|
||||
childGuid,
|
||||
parentLocation,
|
||||
placementId,
|
||||
parentSpawn.InstanceSequence,
|
||||
childPositionSequence));
|
||||
}
|
||||
else if (_loggedUnaddressableParentRefusals.Add(childGuid))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"equipment: parent 0x{parentGuid:X8} unaddressable for child " +
|
||||
$"0x{childGuid:X8} at CreateObject-carried relation accept - " +
|
||||
"refusing (#319 A6; should be structurally unreachable - see " +
|
||||
"RuntimeEntityObjectLifetime.RegisterEntityCore's " +
|
||||
"EnqueueDeferredCreate gate). Logged once for this child; " +
|
||||
"further refusals for the same child are suppressed.");
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryResolveExactAttachment(
|
||||
AttachedChild child,
|
||||
out WorldEntity parent)
|
||||
|
|
@ -831,12 +920,20 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
Relations.Resolve(
|
||||
childGuid,
|
||||
guid => _liveEntities.TryGetSnapshot(guid, out _),
|
||||
guid => _liveEntities.TryGetSnapshot(guid, out WorldSession.EntitySpawn spawn)
|
||||
? spawn.InstanceSequence
|
||||
: null,
|
||||
ResolveLiveParentInstance,
|
||||
_acceptParent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The parent's LIVE incarnation, or null if not currently addressable.
|
||||
/// Shared by <see cref="ResolveRelations"/>'s late-bind lookup and
|
||||
/// <see cref="PrepareAndTryRealize"/>'s #319 F1 commit-time tripwire.
|
||||
/// </summary>
|
||||
private ushort? ResolveLiveParentInstance(uint parentGuid) =>
|
||||
_liveEntities.TryGetSnapshot(parentGuid, out WorldSession.EntitySpawn spawn)
|
||||
? spawn.InstanceSequence
|
||||
: null;
|
||||
|
||||
private bool ResolveAndTryRealize(uint childGuid)
|
||||
{
|
||||
bool projected = false;
|
||||
|
|
@ -896,8 +993,25 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
childCanonical.PositionAuthorityVersion;
|
||||
if (candidateKind is ParentProjectionCandidateKind.Staged)
|
||||
{
|
||||
// #319 A1 (architecture review, 2026-08-05): the incarnation
|
||||
// tripwire MUST be evaluated before either half of the commit
|
||||
// runs. The original shape called CommitStagedParent (the
|
||||
// canonical half) first and let CommitProjection's internal
|
||||
// check throw second - by then the canonical layer had already
|
||||
// been rewritten as parented, so a mismatch tore the
|
||||
// transaction (canonically parented, no committed relation, a
|
||||
// staged relation blocking Resolve forever) instead of
|
||||
// refusing cleanly. This read-only pre-check runs before any
|
||||
// mutation on either side.
|
||||
if (!Relations.CanCommitIncarnation(relation, ResolveLiveParentInstance))
|
||||
{
|
||||
Relations.RejectProjection(relation);
|
||||
return new ProjectionPreparationResult(
|
||||
CanAdvanceWireQueue: true,
|
||||
Projected: false);
|
||||
}
|
||||
if (!_liveEntities.CommitStagedParent(relation, out _)
|
||||
|| !Relations.CommitProjection(relation))
|
||||
|| !Relations.CommitProjection(relation, ResolveLiveParentInstance))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
|
@ -1660,6 +1774,7 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
_pendingReparentRemovalByChild.Clear();
|
||||
_pendingPoseLossRemovalByChild.Clear();
|
||||
_pendingOrphanRemovalByChild.Clear();
|
||||
_loggedUnaddressableParentRefusals.Clear();
|
||||
Relations.Clear();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -548,6 +548,45 @@ AppearanceSynchronization:
|
|||
continue;
|
||||
}
|
||||
|
||||
// #319 F2 (contract §3.1): a committed child is never
|
||||
// independently re-placed by landblock load - retail's
|
||||
// update_object `parent != 0` early-out (@0x00515D40) means
|
||||
// only the parent's own D1/D2 propagation may move it. Without
|
||||
// this gate, a #319-fixed (nonzero) child's cell would make it
|
||||
// a hydration candidate here and take the full legacy
|
||||
// RebucketLiveEntity -> CommitRebucket branch below - a SECOND
|
||||
// canonical cell writer for a child, the exact defect route 7
|
||||
// exists to remove. Same predicate the collision-retirement
|
||||
// sweep already uses (RuntimeSetPositionState.IsAffectedCollisionResident).
|
||||
// Retail review R7 (2026-08-05): the exclusion below covers
|
||||
// the ENTIRE candidate loop (ProjectExact's CreateSupersessionRecovery
|
||||
// and SpatialRecovery branches too, not only the legacy
|
||||
// RebucketLiveEntity branch @0x00515D40 speaks to). The
|
||||
// broader scope rests on route 7's P4 record (a committed
|
||||
// child is never a spatial root, joins no workset, has no
|
||||
// shadow row) plus the fact a child's projection is driven
|
||||
// exclusively through EquippedChildRenderController's
|
||||
// realize/retry path - not on @0x00515D40 alone.
|
||||
// CORRECTION (retail review round 2, D1, 2026-08-05): this gate
|
||||
// is NOT a no-op at HEAD for every parent class. D1's attach
|
||||
// re-cell (RuntimeEntityObjectLifetime.cs's
|
||||
// `parent.Incarnation == parentInstanceSequence` gate) already
|
||||
// matches for a CREATURE/static-range parent pre-fix (the
|
||||
// hardcoded 0 IS that parent's real incarnation), so a
|
||||
// creature-parented child already carries a nonzero canonical
|
||||
// cell at HEAD and was ALREADY a hydration candidate here,
|
||||
// taking RebucketLiveEntity's unconditional
|
||||
// `entity.ParentCellId` write and spatial rebucket - a second
|
||||
// writer competing with route 7 D4's presentation-only
|
||||
// rebucket. This gate removes that live path for the creature
|
||||
// class too, not only the player class the #319 fix newly
|
||||
// affects. Direction is correct (route 7's P4/D4); see the
|
||||
// #319 contract's §7 Half B for the connected-gate watch step
|
||||
// this requires (carry an armed NPC across a landblock
|
||||
// reload).
|
||||
if (_runtime.ParentAttachments.HasCommittedParent(candidate.ServerGuid))
|
||||
continue;
|
||||
|
||||
uint projectionCellId = projection?.ProjectionCellId
|
||||
?? candidate.Snapshot.Position?.LandblockId
|
||||
?? candidate.FullCellId;
|
||||
|
|
|
|||
|
|
@ -217,7 +217,25 @@ public sealed class LiveEntityPresentationController : IDisposable
|
|||
{
|
||||
if (!record.IsSpatiallyProjected
|
||||
|| !record.IsSpatiallyVisible
|
||||
|| record.FullCellId == 0)
|
||||
|| record.FullCellId == 0
|
||||
// #319 F2 (contract §3.2): a committed child never owns an
|
||||
// independent broadphase row (route 7's P4 record,
|
||||
// RuntimeEntityDirectory.cs:451-465). IsSpatiallyProjected and
|
||||
// IsSpatiallyVisible do NOT exclude a child - its presentation-
|
||||
// only rebucket sets IsSpatiallyProjected=true every frame
|
||||
// (LiveEntityRuntime.RebucketLiveEntityPresentationOnly) - so
|
||||
// parentage must be checked directly, the same predicate
|
||||
// LiveEntityHydrationController.OnLandblockLoaded's gate uses.
|
||||
// CORRECTION (architecture review A4, 2026-08-05): the
|
||||
// contract's §3.2 premise ("no-ops today on FullCellId == 0")
|
||||
// holds only for PLAYER-parented children - route 7's D1
|
||||
// already re-cells CREATURE-parented children to a nonzero
|
||||
// cell, so this clause IS a live behavior change for that
|
||||
// class (an NPC's wielded weapon no longer refreshes its
|
||||
// shadow row on a Hidden->Visible edge). Right direction per
|
||||
// route 7's P4 record; the connected gate's Half B must watch
|
||||
// for it explicitly (issue #319 contract §7).
|
||||
|| _liveEntities.ParentAttachments.HasCommittedParent(record.ServerGuid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,19 @@ public sealed class ParentAttachmentState
|
|||
private readonly Dictionary<uint, Queue<DeferredAcceptedParentRelation>>
|
||||
_deferredAcceptedRelationsByParent = [];
|
||||
private ulong _nextDeferredCreateAdmissionId;
|
||||
/// <summary>
|
||||
/// #319 D3/B6 (retail + architecture review round 2, 2026-08-05):
|
||||
/// per-child log-once latch for <see cref="CanCommitIncarnation"/>'s
|
||||
/// refusal. The refusal should be structurally unreachable in
|
||||
/// production; if the invariant it depends on ever breaks for a
|
||||
/// repeating producer, an unconditional log would spam once per
|
||||
/// packet on a host that must survive long endurance sessions (Slice
|
||||
/// K4). One line per distinct child is enough to make the break
|
||||
/// discoverable without a flood. Does not affect
|
||||
/// <see cref="CanCommitIncarnation"/>'s correctness-table purity - it
|
||||
/// touches only this bookkeeping set, never the relation tables.
|
||||
/// </summary>
|
||||
private readonly HashSet<uint> _loggedIncarnationRefusals = [];
|
||||
|
||||
/// <summary>
|
||||
/// Round 5 R5-2: cancellation-aware detach/restore window state, shared
|
||||
|
|
@ -568,7 +581,59 @@ public sealed class ParentAttachmentState
|
|||
}
|
||||
}
|
||||
|
||||
public bool CommitProjection(ParentAttachmentRelation relation)
|
||||
/// <summary>
|
||||
/// #319 A1 (architecture review, 2026-08-05): pure, side-effect-free
|
||||
/// precondition for the commit-time incarnation tripwire. Callable
|
||||
/// BEFORE any canonical or relation-table mutation so a mismatch can be
|
||||
/// refused without ever tearing a transaction — the original shape threw
|
||||
/// from inside <see cref="CommitProjection"/>, which callers reached
|
||||
/// AFTER already committing the canonical half
|
||||
/// (<c>RuntimeEntityObjectLifetime.TryCommitParent</c>), leaving a child
|
||||
/// the canonical layer believed was parented with no committed relation
|
||||
/// and a staged relation blocking <see cref="Resolve"/> forever — the
|
||||
/// exact torn-transaction outcome F1 pinned against. Logs and returns
|
||||
/// <see langword="false"/> on a mismatch; never throws (Route 3's N3
|
||||
/// principle: a possibly-transient condition must not become fatal on a
|
||||
/// host that must survive long endurance sessions). Returns
|
||||
/// <see langword="true"/> (no refusal) when
|
||||
/// <paramref name="resolveParentInstance"/> is null or the parent is not
|
||||
/// currently addressable — the tripwire only fires when the parent IS
|
||||
/// live and DISAGREES with the relation's named incarnation.
|
||||
/// </summary>
|
||||
public bool CanCommitIncarnation(
|
||||
ParentAttachmentRelation relation,
|
||||
Func<uint, ushort?>? resolveParentInstance)
|
||||
{
|
||||
if (resolveParentInstance is null
|
||||
|| resolveParentInstance(relation.ParentGuid) is not { } liveParentInstance
|
||||
|| liveParentInstance == relation.ParentInstanceSequence)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_loggedIncarnationRefusals.Add(relation.ChildGuid))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[parent-attach] refused: child=0x{relation.ChildGuid:X8} names " +
|
||||
$"parent 0x{relation.ParentGuid:X8} incarnation " +
|
||||
$"{relation.ParentInstanceSequence}, but the parent's live " +
|
||||
$"incarnation is {liveParentInstance} (#319 tripwire). " +
|
||||
"Logged once for this child; further refusals for the same " +
|
||||
"child are suppressed.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stages -> committed transition for an accepted parent relation.
|
||||
/// Calls <see cref="CanCommitIncarnation"/> as its own first check
|
||||
/// (before any mutation here) so this method is non-tearing on its own
|
||||
/// terms for ANY caller, in addition to the two production call sites
|
||||
/// that also pre-check before their canonical commit.
|
||||
/// </summary>
|
||||
public bool CommitProjection(
|
||||
ParentAttachmentRelation relation,
|
||||
Func<uint, ushort?>? resolveParentInstance = null)
|
||||
{
|
||||
if (!_stagedByChild.TryGetValue(
|
||||
relation.ChildGuid,
|
||||
|
|
@ -578,6 +643,9 @@ public sealed class ParentAttachmentState
|
|||
return false;
|
||||
}
|
||||
|
||||
if (!CanCommitIncarnation(relation, resolveParentInstance))
|
||||
return false;
|
||||
|
||||
RemoveCommittedChild(relation.ChildGuid);
|
||||
_lastAcceptedByChild[relation.ChildGuid] = relation;
|
||||
var parent = new ParentIncarnation(
|
||||
|
|
@ -794,6 +862,10 @@ public sealed class ParentAttachmentState
|
|||
_recoveryByChild.Remove(childGuid);
|
||||
RemoveCommittedChild(childGuid);
|
||||
_unresolvedByChild.Remove(childGuid);
|
||||
// #319 D3/B6: a recycled guid's next incarnation deserves its own
|
||||
// refusal log rather than silent suppression from a prior, now-dead
|
||||
// incarnation's latch entry.
|
||||
_loggedIncarnationRefusals.Remove(childGuid);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
|
|
@ -812,6 +884,7 @@ public sealed class ParentAttachmentState
|
|||
foreach (List<uint> children in _committedChildrenByParent.Values)
|
||||
children.Clear();
|
||||
_committedChildrenByParent.Clear();
|
||||
_loggedIncarnationRefusals.Clear();
|
||||
}
|
||||
|
||||
private void RemoveDeferredChildCreates(uint childGuid)
|
||||
|
|
|
|||
|
|
@ -1528,7 +1528,15 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
// @0x00515AC6 parent = ...; @0x00515AD1 if (parent->cell != 0)
|
||||
// @0x00515AD6 change_cell(this, parent->cell). The committed
|
||||
// relation (not the guid alone) resolves the parent so a stale or
|
||||
// superseded incarnation can never re-cell the child.
|
||||
// superseded incarnation can never re-cell the child. (Retail
|
||||
// review R4, 2026-08-05: this protection is ACTIVE for the
|
||||
// ParentEvent producer, whose incarnation the wire names and
|
||||
// AP-132 gates on staleness. For the CreateObject producer, #319's
|
||||
// fix makes the committed relation's incarnation always equal the
|
||||
// parent's live value at commit time by construction - the
|
||||
// commit-time tripwire in ParentAttachmentState.CanCommitIncarnation
|
||||
// enforces it - so this sentence is vacuous-by-design there, not a
|
||||
// live protection against a reachable staleness.)
|
||||
if (Entities.ParentAttachments.TryGetCommittedParent(
|
||||
canonical.ServerGuid,
|
||||
out uint parentGuid,
|
||||
|
|
|
|||
|
|
@ -405,9 +405,20 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
return false;
|
||||
}
|
||||
|
||||
// #319 A1 (architecture review, 2026-08-05): same ordering fix as
|
||||
// the graphical host's PrepareAndTryRealize - the incarnation
|
||||
// tripwire must run before TryCommitParent's canonical mutation,
|
||||
// never after, so a mismatch refuses cleanly instead of tearing the
|
||||
// transaction.
|
||||
if (!relations.CanCommitIncarnation(staged, _resolveParentInstance))
|
||||
{
|
||||
relations.RejectProjection(staged);
|
||||
return false;
|
||||
}
|
||||
|
||||
ulong positionAuthorityVersion = canonical.PositionAuthorityVersion;
|
||||
if (!Entities.TryCommitParent(staged, acknowledgeProjection: null, out _)
|
||||
|| !relations.CommitProjection(staged))
|
||||
|| !relations.CommitProjection(staged, _resolveParentInstance))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue