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>
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:749reads slot 8 (instanceSeq) out of the 9-sequence block.src/AcDream.Core.Net/Messages/CreateObject.cs:1156-1158passes it intoParsed.InstanceSequence.src/AcDream.Core.Net/WorldSession.cs:233carries it ontoEntitySpawn.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 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:
- _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:
- The still-owed acceptance gate is unfalsifiable, not failing. The commit
message's criterion is "a session counts only if
[child-cell] cause=propagatelines 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. - 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.
- Diagnostic reporting is wrong.
RuntimeTraceRecorder.OnEntity(src/AcDream.Runtime/GameRuntimeEvents.cs:246-253) is the only non-stub consumer ofdelta.Entity.CellId, and it records0for the player's weapon from theWithdrawndelta atRuntimeEntityObjectLifetime.cs:1551-1558. Correction to an earlier reading of mine: headless bots do not misreport — all fourHeadlessBotPolicy.OnEntityoverloads 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:
LiveEntityHydrationController.OnLandblockLoaded(src/AcDream.App/World/LiveEntityHydrationController.cs:524-600) computesprojectionCellId = projection.ProjectionCellId ?? Snapshot.Position?.LandblockId ?? candidate.FullCellId(:551-553) and filters onprojectionCellId != 0(:554). A cell-less child is skipped — correct, sinceEquippedChildRenderControllerowns it. With a non-zero cell the weapon becomes a candidate and takes the full legacyRebucketLiveEntitybranch (:594-596), which writesentity.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.LiveEntityPresentationController.RestoreShadow(:216-236) no-ops today onrecord.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-220checks neitherProjectionKindnor parentage.RuntimeInitialCreateResidenceState.Beginrefuses to open a lease whenrecord.FullCellId != 0u(:583), andTryConvertToCellessRouterefuses at:1041. Whether a re-CreateObjectfor 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:
- Its parent is not a player.
const uint parentGuid = 0x70000020u(:340) — a static-range guid, spawned withincarnation: 1(:342). - Its relation is a standalone
ParentEvent, not a CreateObject.sink.ParentUpdated(new ParentEvent.Parsed(..., ParentInstanceSequence: 1, ...))(:358-364) supplies the parent's real incarnation, andParentAttachmentState.Resolve(:440-454) validates it againstresolveInstance(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.Incarnationis stable for the whole session: inbound updates are gated byIsCurrentInstanceand rejected on mismatch, never merged (InboundPhysicsStateController.cs:264, 1011). The mismatch is present from the very first commit. - NOT a
parent.FullCellId == 0timing race. The player's canonical cell is non-zero throughout play — it has to be, orGetRootObjectClockDispositionwould returnSuspendandRuntimeLocalPlayerFrameController.AdvanceBeforeNetwork:96-110would never callcontroller.Update, i.e. the player could not move at all. - NOT a rendering or visibility bug. The draw path has an explicit
entity.ParentCellIdfallback and the presentation rebucket route 7 kept (RebucketEquippedChildPresentation) is keyed on the child guid alone and works fine. - NOT fixable by reverting only the
TickChildhalf of route 7. That would restore the masking write and re-create the two-writer problem route 7 exists to remove.