acdream/docs/research/2026-08-05-local-player-child-propagation.md
Erik 195667db94 research: file #319 — player-parented children never get a canonical cell, and route 7's gate cannot see it
Found by asking why route 7's connected gate stayed thin (one cause=propagate
across 5-6 equipped landblock crossings) instead of recording the thinness and
moving on.

EquippedChildRenderController.cs:134 hardcodes ParentInstanceSequence: 0 for a
parented CreateObject. Correct for creatures and statics, which are genuinely
sequence 0; wrong for players, whose ObjectInstance is Character.TotalLogins
(ACE Player_Networking.cs:37). The relation files under (playerGuid, 0) while
the record carries TotalLogins, so both route-7 write sites — D1's attach
re-cell and D2's propagation lookup — key on an incarnation that never matches.
TryCommitParent does not validate the sequence, so the attach succeeds and
prints normally.

This is a cd3129e9 (route 7) REGRESSION 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. Scope is wider than the local
player — every remote player's equipment is affected. Proven by class: every
probe-firing parent across both captured gate logs is 0x7/0x8 (sequence 0);
the sole 0x5 player parent is the sole failure.

User-visible consequence is NIL and that was verified rather than assumed —
rendering has an explicit fallback and attached children are structurally
excluded from spatial roots, physics worksets, collision retirement, radar and
picking.

THE FINDING THAT OUTRANKS THE DEFECT, and it is a flaw in my own gate design:
route 7's owed gate accepts a session "only if cause=propagate lines appear".
A zero-cell player child emits NO line, so the defect's signature is ABSENCE,
which that criterion reads as "not exercised" rather than "broken". Two
captured gate logs contain the defect and neither flags it. A gate that cannot
fail in the presence of its own target bug is worse than no gate — it
manufactures confidence. This is the same shape as route 3's round-2
regression, which I criticised at length in the closeout while shipping this.

The fix is deliberately NOT attempted here: it has more blast radius than the
bug. The player's canonical cell does not track the player during ordinary
movement, so correcting the key alone yields a stale cell rather than a right
one; and three sites are inert only because the cell is zero and would wake on
a fix (the hydration projectionCellId filter, RestoreShadow's broadphase row,
and the initial-create residence FullCellId refusal).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 09:59:35 +02:00

24 KiB

The local player's equipped child never gets a canonical cell

Date: 2026-08-05 Mode: investigation, report-only. No production or test code changed. Worktree: .claude/worktrees/peaceful-visvesvaraya-e0a196, branch claude/acdream-physics-divergence-5aa784, HEAD 09911821. Evidence logs: c5-gates.log, c4-gates.log (both in the worktree root, both captured with ACDREAM_PROBE_CHILD_CELL=1).


Verdict up front

No — the local player's equipped child never receives a canonical cell, and it cannot follow the player across a cell boundary. Its RuntimeEntityRecord.FullCellId stays 0 for the whole attached lifetime.

This is a C4 route 7 regression (commit cd3129e9), triggered by a pre-existing latent bug that route 7 removed the masking fallback for. The latent bug is a hard-coded ParentInstanceSequence: 0 at src/AcDream.App/Rendering/EquippedChildRenderController.cs:134, which is wrong for exactly one class of parent: players. It dates from 8dd99605/fe551496, long before route 7.

Scope is wider than the local player. Any child whose parent is a player (guid 0x5xxxxxxx) and whose relation arrives on a CreateObject — i.e. the local player's own equipment at login, and every remote player's equipment as they come into view — is affected. Children of creatures/NPCs/statics (0x7xxxxxxx/0x8xxxxxxx) are unaffected, which is why 19 of the 20 [child-cell] lines in c5-gates.log look healthy.

The observable consequence for the user is nil — see §5. Rendering, physics, picking, radar, VFX, and landblock teardown all either exclude attached children structurally or have an explicit entity.ParentCellId fallback. The real cost is diagnostic and architectural: route 7's own headline invariant is silently false for the player, and — worse — a zero-cell child makes the still-owed connected acceptance gate unfalsifiable rather than failing loudly, because that gate's criterion is the presence of cause=propagate probe lines.


1. The symptom, restated from the logs

c5-gates.log line 343 and c4-gates.log line 238 both show the App-side attach for the local player's weapon:

equipment: attached child=0x800045EE parent=0x5000000A location=RightHand placement=RightHandCombat

with no [child-cell] line before it. Every other attach in both logs has one, e.g. c5-gates.log 336-337:

[child-cell] parent=0x70007059 child=0x8000515D old=0x00000000 new=0x0007014B cause=attach
equipment: attached child=0x8000515D parent=0x70007059 location=RightHand placement=RightHandCombat

The probe was demonstrably live at the time — in c4-gates.log two probe lines (210, 211) fire before the player's attach at 238.

Across 5-6 landblock crossings with the weapon equipped, zero cause=propagate lines name 0x5000000A.

The pattern that names the cause

Sort every successful attach in both logs by parent guid prefix:

parent prefix ACE range (references/ACE/Source/ACE.Entity/ObjectGuid.cs:21-34) probe fired?
0x5xxxxxxx player (PlayerMin 0x50000001 .. PlayerMax 0x5FFFFFFF) never (1 case: 0x5000000A)
0x7xxxxxxx static landblock object (StaticObjectMin 0x70000000) always
0x8xxxxxxx dynamic (DynamicMin 0x80000000) always

Players are the only failing class. That is not a coincidence — see §2.


2. Root cause: the committed relation is filed under the wrong parent incarnation

2.1 ACE gives players a non-zero object-instance sequence

references/ACE/Source/ACE.Server/WorldObjects/Player_Networking.cs:34-37:

Character.TotalLogins++;
CharacterChangesDetected = true;

Sequences.SetSequence(SequenceType.ObjectInstance, new UShortSequence((ushort)Character.TotalLogins));

A player's ObjectInstance sequence is its lifetime login count. For the +Acdream test character that is a large number; for a fresh creature, item, or landblock NPC it is 0.

That value is written into the CreateObject physics-descriptor timestamp block at references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Networking.cs:419 (writer.Write(Sequences.GetCurrentSequence(SequenceType.ObjectInstance)); // 8).

2.2 acdream parses it and stores it as the record's Incarnation

  • src/AcDream.Core.Net/Messages/CreateObject.cs:749 reads slot 8 (instanceSeq) out of the 9-sequence block.
  • src/AcDream.Core.Net/Messages/CreateObject.cs:1156-1158 passes it into Parsed.InstanceSequence.
  • src/AcDream.Core.Net/WorldSession.cs:233 carries it onto EntitySpawn.InstanceSequence.
  • src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs:43: public ushort Incarnation => Snapshot.InstanceSequence;

So for the local player, record.Incarnation == TotalLogins, not 0.

2.3 acdream hard-codes 0 when a CreateObject carries a parent

src/AcDream.App/Rendering/EquippedChildRenderController.cs:129-135:

Relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
    parentGuid,
    spawn.Guid,
    parentLocation,
    placementId,
    ParentInstanceSequence: 0,
    spawn.PositionSequence));

This is structurally forced by the wire: the child's CreateObject physics descriptor carries the parent's guid and location only — there is no field for the parent's instance sequence. The correct value is available (_liveEntities.TryGetSnapshot(parentGuid).InstanceSequence, which is exactly what ParentAttachmentState.Resolve uses at src/AcDream.App/Rendering/EquippedChildRenderController.cs:834-836 for the ParentEvent path) — it is simply not read here.

ParentAttachmentState.Resolve cannot correct it either: it early-returns on a relation that is already staged (src/AcDream.Runtime/Entities/ParentAttachmentState.cs:425-426), and AcceptCreateObjectRelation stages directly (ParentAttachmentState.cs:391-394).

Nor does the commit validate it: RuntimeEntityObjectLifetime.TryCommitParent (:1400-1412) forwards guid, location, placement and the child's position sequence — it never passes relation.ParentInstanceSequence to a gate. So the attach succeeds and equipment: attached prints normally.

2.4 The relation is then filed under (playerGuid, 0)

ParentAttachmentState.CommitProjection (src/AcDream.Runtime/Entities/ParentAttachmentState.cs:571-597):

_lastAcceptedByChild[relation.ChildGuid] = relation;
var parent = new ParentIncarnation(
    relation.ParentGuid,
    relation.ParentInstanceSequence);   // == 0
...
children.Add(relation.ChildGuid);

2.5 Both route-7 write sites read the parent's real incarnation, so both miss

D1 (attach re-cell)RuntimeEntityObjectLifetime.CommitAcceptedParentCellless, :1532-1549:

if (Entities.ParentAttachments.TryGetCommittedParent(
        canonical.ServerGuid, out uint parentGuid, out ushort parentInstanceSequence)
    && Entities.TryGetActive(parentGuid, out RuntimeEntityRecord parent)
    && parent.Incarnation == parentInstanceSequence      // TotalLogins == 0  -> FALSE
    && parent.FullCellId != 0u)

D2 (crossing propagation)RuntimeEntityDirectory.PropagateFullCellToChildren, :478-480:

IReadOnlyList<uint> children = ParentAttachments.ChildrenAttachedToParent(
    current.ServerGuid,
    current.Incarnation);                                 // key (guid, TotalLogins)

ChildrenAttachedToParent (ParentAttachmentState.cs:681-691) looks up _committedChildrenByParent[(guid, TotalLogins)], but the child was filed under (guid, 0), so it returns Array.Empty<uint>()forever, for every cell write the player ever makes.

One cause, both observed negatives. No other hypothesis explains the exact player-vs-non-player split in the logs.

2.6 A counter-reading, recorded because it is the easy mistake

An independent read of this code concluded "the key pair matches by construction; the lookup is not the failure mode", reasoning from ParentAttachmentState.Resolve's parentInstance.Value == relation.ParentInstanceSequence check (:440-454). That is true for the ParentEvent path and false for the CreateObject path, because AcceptCreateObjectRelation stages directly (:391-394) and Resolve early-returns on anything already staged (:425-426). Anyone auditing this should check AcceptCreateObjectRelation's producer, not Resolve's consumer.

The same read independently traced that the propagation mechanism would otherwise fire for the local player on every frameProject -> RebucketLiveEntity -> CommitRebucket -> SetFullCell -> PropagateFullCellToChildren, unconditionally (RuntimeEntityDirectory.cs:355). That agreement matters: the plumbing is correct and live; only the key is wrong.


3. Why this is a route-7 regression, not a pre-existing gap

Before cd3129e9, EquippedChildRenderController.TickChild wrote the child's canonical cell every frame from the parent's App-side cell:

-                _liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId);
+                EquippedChildPresentationRebucketDisposition disposition =
+                    _liveEntities.RebucketEquippedChildPresentation(
+                        child.ChildGuid, parentCellId);

The old RebucketLiveEntity reached CommitRebucket (src/AcDream.App/World/LiveEntityRuntime.cs:942-945), and CommitRebucket writes the canonical cell (RuntimeEntityObjectLifetime.cs:1959-1963 Entities.SetFullCell(canonical, fullCellId, canonicalLandblockId)). The replacement, RebucketLiveEntityPresentationOnly, deliberately never calls CommitRebucket (documented at LiveEntityRuntime.cs:1026-1028).

The old path took the parent's cell from parent.ParentCellId on the WorldEntity and keyed on nothing but the child guid. It was structurally immune to the incarnation bug, and it tracked the local player exactly, because LocalPlayerProjectionController.Project writes entity.ParentCellId = movement.CellId every frame (src/AcDream.App/Input/LocalPlayerProjectionController.cs:79).

So route 7 replaced a correct-by-accident write with a correct-by-design write whose design has a broken key. The register row AP-142 and the route-7 contract both assume the propagation hook is reached; for a player parent it never is.


4. A second finding: even with the key fixed, the player's canonical cell does not track the player

This is independent of the incarnation bug and worth knowing before anyone writes the fix.

The local player's canonical FullCellId is written in only three ways (traced end-to-end):

when site
login activation (first non-zero) src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs:2741-2745
accepted inbound Position / ForcePosition RuntimeEntityDirectory.RefreshSnapshot -> RuntimeEntityRecord.cs:234
teleport / portal placement commit RuntimeSetPositionState.cs:5001-5007; LocalPlayerTeleportController.cs:255

Ordinary WASD movement never writes it. src/AcDream.App/Input/LocalPlayerProjectionController.cs:85-98 builds a landblock id (low 16 bits forced to 0xFFFF in both the indoor branch at :90 and the outdoor branch at :97) and passes it to RebucketLiveEntity at :109. LiveEntityRuntime.cs:935-938 then computes:

uint committedFullCell =
    (spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu
        ? spatialCellOrLandblockId
        : record.FullCellId;      // landblock id -> preserve the old cell

so CommitRebucket re-commits the identical cell and takes the previous == fullCellId early-out (RuntimeEntityObjectLifetime.cs:1965-1972).

Nothing in src/AcDream.Runtime/Gameplay/ reads or writes the canonical cell at all (grep -rn "FullCellId\|SetFullCell" src/AcDream.Runtime/Gameplay/ returns zero hits), and the local player is explicitly excluded from the ordinary physics updater before CommitOrdinaryCell can run (src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs:183-227, returning at :227 ahead of the _ordinaryPhysics.Tick at :376).

Consequence for the fix: repairing the incarnation key alone would give the player's weapon the login/teleport cell, not the player's current cell. Route 7's model ("the parent's canonical cell write is the propagation trigger") is sound for remotes and creatures, whose cells are server-driven, but the local player is client-authoritative and its canonical cell is a coarse, mostly-frozen value. Whoever fixes this needs to decide which of the two is the child's source of truth for a client-authoritative parent.


5. Does it matter? Observable consequence

Rendering: no. LiveRenderProjectionJournal.Project (src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs:269-276) falls back explicitly:

uint fullCellId = record.FullCellId != 0
    ? record.FullCellId
    : entity.ParentCellId ?? 0;

and TickChild keeps child.Entity.ParentCellId = parent.ParentCellId current every frame (EquippedChildRenderController.cs:403). The weapon draws, culls, and moves with the hand exactly as before. The user should see nothing wrong.

Liveness/residency predicates: no. The two FullCellId == 0 guards that gate real behaviour — LiveEntityRuntime.GetRootObjectClockDisposition (:2602-2612) and HasSpatialRuntimeProjection (:3340-3345) — both also require ProjectionKind is LiveEntityProjectionKind.World. An attached child is always Attached, so it is excluded on the first clause regardless of its cell. Route 7's own P4 note (RuntimeEntityDirectory.cs:451-465) says the same thing for the physics/broadphase side: a committed child never becomes a spatial root and never joins a workset or shadow list.

Landblock retirement / teardown: no — checked from both ends. CanonicalLandblockId has exactly one production reader outside the record and the propagation pair (LiveRenderProjectionJournal.cs:273-274), and that one has a fallback. The App unload path, GpuWorldState.DetachLandblock (src/AcDream.App/Streaming/GpuWorldState.cs:1188-1317), never destroys live entities at all — non-persistent projections park in _pendingByLandblock and merge back on reload (:1228-1232, :1290-1294) — and it selects from the presentation buckets, which for an attached child were set from parent.ParentCellId. The Runtime retirement sweep, RuntimeSetPositionState.IsAffectedCollisionResident (:3930-3946), is cell-keyed but is triple-gated against attached children: spatial-roots-only iteration (:3750), IsSpatialRoot (:3942), and an explicit !ParentAttachments.HasCommittedParent(record.ServerGuid) (:3944-3945). The weapon is neither wrongly destroyed nor wrongly retained.

Radar: no. ILiveEntityRadarSource (src/AcDream.App/World/ILiveEntityRadarSource.cs:10-15) is entirely WorldEntity-based; it never reads a canonical cell.

Picking / interaction: no. src/AcDream.Core/Selection/WorldPicker.cs contains zero cell references; picking is ray/bounds-based.

VFX and scripts on the weapon: no. EntityEffectController (src/AcDream.App/Rendering/Vfx/EntityEffectController.cs:445-479) redirects an attached child to its parent (:450-451, :460-461) and never consults the child's own cell — retail-faithful, matching update_object's parent early-out.

Object clock: the zero is the correct answer anyway. An attached child's clock is deliberately suspended (CommitAcceptedParentCellless -> SuspendObjectClock, RuntimeEntityObjectLifetime.cs:1523), and GetRootObjectClockDisposition already returns Suspend on the ProjectionKind is not World clause two conditions earlier.

What DOES change:

  1. The still-owed acceptance gate is unfalsifiable, not failing. The commit message's criterion is "a session counts only if [child-cell] cause=propagate lines appear". A zero-cell player child produces no line — the absence reads as "nothing happened" rather than "this is broken". Two captured gate logs (c4-gates.log, c5-gates.log) contain the defect and neither flags it. This is the thing to escalate.
  2. Route 7's headline invariant is false for every player-parented child. AP-142's model assumes the propagation hook is reached; for a player parent it never is, so the register row describes behaviour the code does not have.
  3. Diagnostic reporting is wrong. RuntimeTraceRecorder.OnEntity (src/AcDream.Runtime/GameRuntimeEvents.cs:246-253) is the only non-stub consumer of delta.Entity.CellId, and it records 0 for the player's weapon from the Withdrawn delta at RuntimeEntityObjectLifetime.cs:1551-1558. Correction to an earlier reading of mine: headless bots do not misreport — all four HeadlessBotPolicy.OnEntity overloads are empty method bodies (src/AcDream.Headless/Policies/HeadlessBotPolicy.cs:48, 151, 281, 516), and the three view queries there read the local player's own guid only. The exposure is trace/diagnostic, not bot behaviour.

So: not a user-visible defect today, but a real correctness defect in the canonical layer, and precisely the class of stale/zero-cell residue that AP-142 clause (a) exists to reject.

5.1 The fix has more blast radius than the bug

Three call sites are currently inert only because the child's cell is zero. Fixing the key will wake them, so they need checking before, not after:

  1. LiveEntityHydrationController.OnLandblockLoaded (src/AcDream.App/World/LiveEntityHydrationController.cs:524-600) computes projectionCellId = projection.ProjectionCellId ?? Snapshot.Position?.LandblockId ?? candidate.FullCellId (:551-553) and filters on projectionCellId != 0 (:554). A cell-less child is skipped — correct, since EquippedChildRenderController owns it. With a non-zero cell the weapon becomes a candidate and takes the full legacy RebucketLiveEntity branch (:594-596), which writes entity.ParentCellId = spatialCellOrLandblockId (LiveEntityRuntime.cs:885-898) — overwriting, for one frame, the value the render tick maintains. Likely a transient (TickChild restores it next frame), but it is a two-writer window route 7 exists to remove.
  2. LiveEntityPresentationController.RestoreShadow (:216-236) no-ops today on record.FullCellId == 0 (:220). With a non-zero cell it would install a collision-shadow row for an equipped weapon on a Hidden->Visible edge (:199) — contradicting route 7's own P4 claim that "no child broadphase registration exists to rebuild" (RuntimeEntityDirectory.cs:450-465). The guard at :218-220 checks neither ProjectionKind nor parentage.
  3. RuntimeInitialCreateResidenceState.Begin refuses to open a lease when record.FullCellId != 0u (:583), and TryConvertToCellessRoute refuses at :1041. Whether a re-CreateObject for an equipped item reuses the same record (in which case an inherited non-zero cell would block its residence) was not established and should be checked.

6. Reconciling the passing headless test

RuntimeLiveEntitySessionControllerTests .DirectSink_D5_StandaloneParentEventCommitsChildToParentsExactCell (tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs:324-368) passes for two reasons, neither of which touches the defect:

  1. Its parent is not a player. const uint parentGuid = 0x70000020u (:340) — a static-range guid, spawned with incarnation: 1 (:342).
  2. Its relation is a standalone ParentEvent, not a CreateObject. sink.ParentUpdated(new ParentEvent.Parsed(..., ParentInstanceSequence: 1, ...)) (:358-364) supplies the parent's real incarnation, and ParentAttachmentState.Resolve (:440-454) validates it against resolveInstance(parentGuid) before staging. Key match, propagation works.

The framing "a headless bot IS a local player" does not hold for this test: the test drives a remote-shaped entity through the first-entry conductor, and RuntimeLiveEntitySessionController.ResolveAndCommitChildAttachment (:387-424) only ever handles ParentEvent-sourced relations, which always carry the true sequence. The headless drive has no AcceptCreateObjectRelation equivalent at all — so it cannot reproduce the bug, and equally, headless never commits a CreateObject-carried equip.

The one path that would have caught this is the gate the route-7 contract itself specified and the commit lists as STILL OWED (docs/research/2026-08-04-c4-route-7-contract.md:788-793): "one headless session where the local player equips (via the bot command surface) and crosses a boundary".


7. Prediction worth testing before designing the fix

If the user unequips and re-equips the weapon mid-session, ACE sends a ParentEvent (GameMessageParentEvent.cs:16 writes the wielder's real ObjectInstance sequence). That path goes through Relations.Enqueue -> Resolve, which validates and preserves the true sequence, so the relation would be filed under (0x5000000A, TotalLogins) and both D1 and D2 should start working for that weapon, with cause=attach appearing immediately.

If that is observed, it confirms the diagnosis end-to-end with no code change. It would also mean the bug is "login-equipped items only" in practice — which is still every item on every character at every login.


8. Sketch of the fix (for approval, not applied)

Two independent pieces:

(a) The key. EquippedChildRenderController.OnSpawn should resolve the parent's live incarnation instead of assuming 0: _liveEntities.TryGetSnapshot(parentGuid, out spawn) ? spawn.InstanceSequence : ... — the same lookup ResolveRelations already passes to Resolve at :834-836. The "parent not yet known" case must keep the relation unresolved (the deferred path already exists) rather than committing under a guessed key. This alone restores D1/D2 for player parents, and also fixes remote players' equipment.

Worth auditing at the same time: ParentAttachmentRelation.ParentInstanceSequence is written by exactly two producers (AcceptCreateObjectRelation and Enqueue), and only one of them is correct today. A commit-time assertion that relation.ParentInstanceSequence == parentRecord.Incarnation would have made this loud instead of silent.

(b) The source of truth for a client-authoritative parent (§4). Options: either make the local player's canonical cell track its movement (an exact-cell rebucket instead of the landblock-only one, which has knock-on effects on isOrdinaryRoot and the animation-scheduler exclusion), or accept that the child follows the parent's coarse cell and document the divergence. This is a design call, not a bug fix, and should be decided before (a) lands — (a) alone will start writing a stale-but-nonzero cell where there is currently a zero.


9. What this is NOT

  • NOT an incarnation-drift bug. record.Incarnation is stable for the whole session: inbound updates are gated by IsCurrentInstance and rejected on mismatch, never merged (InboundPhysicsStateController.cs:264, 1011). The mismatch is present from the very first commit.
  • NOT a parent.FullCellId == 0 timing race. The player's canonical cell is non-zero throughout play — it has to be, or GetRootObjectClockDisposition would return Suspend and RuntimeLocalPlayerFrameController.AdvanceBeforeNetwork:96-110 would never call controller.Update, i.e. the player could not move at all.
  • NOT a rendering or visibility bug. The draw path has an explicit entity.ParentCellId fallback and the presentation rebucket route 7 kept (RebucketEquippedChildPresentation) is keyed on the child guid alone and works fine.
  • NOT fixable by reverting only the TickChild half of route 7. That would restore the masking write and re-create the two-writer problem route 7 exists to remove.