From 195667db946b1b17acc1c8838ba3ff7ee4182bf2 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 5 Aug 2026 09:59:35 +0200 Subject: [PATCH] =?UTF-8?q?research:=20file=20#319=20=E2=80=94=20player-pa?= =?UTF-8?q?rented=20children=20never=20get=20a=20canonical=20cell,=20and?= =?UTF-8?q?=20route=207's=20gate=20cannot=20see=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/ISSUES.md | 72 +++ ...26-08-05-local-player-child-propagation.md | 494 ++++++++++++++++++ 2 files changed, 566 insertions(+) create mode 100644 docs/research/2026-08-05-local-player-child-propagation.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 4784862c..8db48f3d 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -13260,3 +13260,75 @@ preserved verbatim, not fixed, per this row's own resolution above. Covered by `LandingPacket_PlayerGuid_QueueClearedNoShadowPublish_316Preserved` / `LandingPacket_CreatureGuid_ShadowPublishedQueueNotCleared` in `tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs`. + +## #319 — A player-parented child never receives a canonical cell (ParentInstanceSequence hardcoded 0) + +**Status:** OPEN +**Severity:** LOW for the user (no observable symptom — verified, not assumed), +HIGH for process (it defeats route 7's own connected gate; see below) +**Filed:** 2026-08-05 +**Component:** physics / entity lifetime / equipped children +**Regressed by:** `cd3129e9` (C4 route 7), which un-masked a pre-existing latent +bug rather than creating it. + +**Root cause.** `EquippedChildRenderController.cs:134` hardcodes +`ParentInstanceSequence: 0` for a `CreateObject` carrying a parent. Correct for +creatures and statics, which genuinely are sequence 0. WRONG for players: ACE +sets a player's `ObjectInstance` to `Character.TotalLogins` +(`Player_Networking.cs:37`), which acdream parses into +`RuntimeEntityRecord.Incarnation`. The relation is therefore filed under +`(playerGuid, 0)` while the record carries `TotalLogins`, and BOTH route-7 +write sites key on the record's real incarnation: +- D1 attach re-cell — `RuntimeEntityObjectLifetime.cs:1537` + `parent.Incarnation == parentInstanceSequence` → false. +- D2 propagation — `RuntimeEntityDirectory.cs:478-480` + `ChildrenAttachedToParent(guid, current.Incarnation)` → empty, forever. + +`TryCommitParent` never validates the sequence, so the attach succeeds and +`equipment: attached` prints normally. Silent. + +**Why route 7 owns it.** The deleted `TickChild` call reached +`RebucketLiveEntity` → `CommitRebucket` → `SetFullCell` keyed on the CHILD guid +alone, sourced from the parent's App-side `ParentCellId` — structurally immune +to a wrong parent key, and it tracked the local player exactly. Its replacement +`RebucketLiveEntityPresentationOnly` deliberately never commits canonical cell +(`LiveEntityRuntime.cs:1026-1028`). + +**Scope is wider than the local player: every REMOTE player's equipment too.** +Proven by class rather than anecdote — every probe-firing parent across both +captured gate logs is `0x7…` (static) or `0x8…` (dynamic), i.e. sequence 0; the +sole `0x5…` player parent is the sole failure. + +**User-visible consequence: NIL, verified.** Rendering has an explicit fallback +(`LiveRenderProjectionJournal.cs:270-277`); attached children are structurally +excluded from spatial roots, physics/projectile worksets, the collision +retirement sweep, radar, and `WorldPicker`; VFX redirect to the parent; +landblock unload parks rather than destroys. Headless bots do not misreport. + +**THE PROCESS FINDING, which outranks the defect.** Route 7's still-owed +connected 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 the criterion reads as "not exercised" rather than "broken". Two captured +gate logs contain this defect and neither flags it. **The criterion is +unfalsifiable in the presence of the bug it exists to catch**, which is worse +than having no gate, because it manufactures confidence. Corrected in the +closeout handoff: the gate must now assert a POSITIVE — the equipped child's +`FullCellId` equals the parent's after a crossing — not merely count probe +lines. + +**Do NOT rush the fix; it has more blast radius than the bug.** +1. The player's canonical cell does not track the player during ordinary + movement (only login activation, inbound Position/ForcePosition, and + teleport write it; WASD passes a landblock id that + `LiveEntityRuntime.cs:935-938` explicitly preserves the old cell for). So + correcting the key ALONE yields a stale cell, not a correct one. +2. Three sites are inert only because the cell is zero and would wake on a fix: + the hydration `projectionCellId != 0` filter + (`LiveEntityHydrationController.cs:551-554`, opens a two-writer window), + `RestoreShadow` (`LiveEntityPresentationController.cs:216-236`, installs a + broadphase row route 7 says should not exist), and + `RuntimeInitialCreateResidenceState.Begin`'s `FullCellId != 0` refusal + (`:583`). + +Full analysis, with the headless test's structural immunity explained: +[`2026-08-05-local-player-child-propagation.md`](research/2026-08-05-local-player-child-propagation.md) diff --git a/docs/research/2026-08-05-local-player-child-propagation.md b/docs/research/2026-08-05-local-player-child-propagation.md new file mode 100644 index 00000000..00b7b325 --- /dev/null +++ b/docs/research/2026-08-05-local-player-child-propagation.md @@ -0,0 +1,494 @@ +# 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.