# 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`: ```csharp 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`: ```csharp 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`): ```csharp _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`: ```csharp 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`: ```csharp IReadOnlyList 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()` — **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 frame** — `Project` -> `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: ```diff - _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: ```csharp 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: ```csharp 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.