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.