diff --git a/docs/research/2026-08-04-c4-routes-6-7-scoping.md b/docs/research/2026-08-04-c4-routes-6-7-scoping.md new file mode 100644 index 00000000..edc7f497 --- /dev/null +++ b/docs/research/2026-08-04-c4-routes-6-7-scoping.md @@ -0,0 +1,672 @@ +# C4 routes 6 and 7 — scoping (2026-08-04) + +Read-only research. Worktree +`C:\Users\erikn\source\repos\acdream\.claude\worktrees\peaceful-visvesvaraya-e0a196`, +branch `claude/acdream-physics-divergence-5aa784`, HEAD `d5bdc355`. + +Scoped together deliberately: the campaign groups route 6 (drops + +split-recovery marking) and route 7 (residual pickup/parent/delete polish) as +adjacent item/container work. **They turn out not to share a single production +file.** The reason to keep them in one document is different from the reason +they were paired: both routes' premises in +[`2026-08-02-cutover-route-inventory.md`](2026-08-02-cutover-route-inventory.md) +were written **before C3c flipped both hosts' Create paths onto the residence +lease**, and both are now substantially or entirely satisfied by that flip. +Splitting the scoping would have produced two documents each re-deriving the +same C3c correction. + +**Headline: route 6 requires no production change. Route 7 has exactly one +real defect, and it is a two-writer split on the child's canonical cell — +which is also the reason headless has no parent-realize sequence at all.** + +--- + +## 0. The correction that governs both routes + +The inventory's route-6 and route-7 sections both rest on this claim +(`2026-08-02-cutover-route-inventory.md:759-767`, repeated at `:806-824`): + +> `RegisterEntityWithInitialResidence` is never called from any production +> path (confirmed by full-repo grep …). Both drop flavors equally bypass the +> canonical create-placement transaction. + +**That is no longer true.** C3c (`529e0e9d`) flipped both hosts: + +- Graphical: `src/AcDream.App/World/LiveEntityRuntime.cs:544-548` — + `_entityObjects.RegisterEntityWithInitialResidence(incoming, isLocalPlayer, + RetirePriorProjection)`, with the C3c rationale comment at `:538-543`. +- Direct/headless: `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:118-123` + — the same call, except for the deliberately-excluded content-less host + (comment `:108-117`). + +Route 7's inventory paragraph "these cancellation calls are structurally +present but functionally no-ops against always-empty state" is likewise +obsolete: `InitialCreateResidences` is populated in production now, so every +`ForgetInitialCreateResidence` in the pickup/parent/delete family is live. + +Anything in the inventory's routes 6/7 sections that reasons from +"nothing upstream to cancel" must be re-derived, not cited. + +--- + +# ROUTE 6 — drops and unparent-to-world + +## 6.1 What the route actually is + +**There is no route-6 classifier disposition and no route-6 +`RuntimePositionEntityKind`.** A dropped item is an ordinary non-local +CreateObject: + +- `RuntimePositionEntityKind.Remote` + (`src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs:8-14`). +- `RuntimeCreateResidenceKind.TopLevel`, produced at + `src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs:603-606` + (`Parented` when the wire frame carries a parent, `PickedUp` when it has + neither parent nor position). +- `ClassifyCreate` (`RuntimeAuthoritativePositionRouteClassifier.cs:205-275`) + returns disposition `SetPosition` with + `InitialCreateFlags = Placement | Slide` (`:168-170`, applied `:254-258`). + +That is byte-for-byte route 1's create classification. Route 6 is a *source* +of route-1 traffic, not a route of its own. + +## 6.2 Both drop flavors already converge on the canonical transaction + +**Whole-item drop.** `ItemInteractionController.ExecutePlacementActions`, +`DropToWorld` case +(`src/AcDream.App/UI/ItemInteractionController.cs:974-991`): optimistic +`MoveItemOptimistic(objectId, newContainerId: 0u, newSlot: -1)` then +`_sendDrop(objectId)`. No physics, no position. The server's CreateObject +comes back through the ordinary wire pump into +`LiveEntityHydrationController.OnCreate` → `_runtime.RegisterLiveEntity` → +`RegisterEntityWithInitialResidence` (`LiveEntityRuntime.cs:544-548`). + +**Split-to-world.** Same file `:992-1013` (`SplitToWorld` case) raises +`WorldDropDispatched`; `InventoryWorldDropProjectionController.OnWorldDropDispatched` +(`src/AcDream.App/World/InventoryWorldDropProjectionController.cs:81-98`) +records the pending identity; the new GUID's F748 Position is recovered by +`TryRecoverUnknownPosition` (`:54-68`), which calls **`_hydration.OnCreate(spawn)` +at `:66` — the identical entry point**. Same residence lease, same conductor, +same placement. + +**Therefore the campaign handoff's route-6 requirement +(`2026-07-31-remaining-physics-campaign-handoff.md:357-362`) — +"`TryRecoverUnknownPosition` may create the logical object, but it must enter +the same canonical create-placement transaction" — is already met, by C3c, with +zero route-6-specific code.** + +## 6.3 "Do not expose a stale source position" — satisfied + +`PendingSplitToWorldProjection.BuildSpawn` +(`InventoryWorldDropProjectionController.cs:171-209`) overrides every +positional field from the arriving update: + +| Field | Line | +|---|---| +| `Physics.Position` | `:179` | +| `Physics.Parent = null` | `:180` | +| `Physics.Velocity` | `:181` | +| `Physics.Timestamps.{Position,Teleport,ForcePosition,Instance}` | `:182-188` | +| DTO `Position` | `:194` | +| DTO `ContainerId = 0`, `WielderId = 0`, `CurrentWieldedLocation = 0` | `:197-199` | +| DTO `{Instance,Position}Sequence`, `ParentGuid = null`, `ParentLocation = null`, `PlacementId` | `:200-206` | + +No source pose survives into the synthetic spawn. + +## 6.4 "Do not replay create-time effects" — NOT ESTABLISHED as a defect + +The inventory calls this "a genuine new capability, not merely dormant" +(`2026-08-02-cutover-route-inventory.md:768-775`). I could not find a reachable +defect behind it, and the retail decomp says there should not be one. + +**acdream's only create-time effect replay is the F754/F755 network queue +drain, keyed by server GUID.** `LiveEntityReadyPublisher.Publish` calls +`effects.ReplayPendingForLiveEntity(record.ServerGuid)` +(`src/AcDream.App/World/LiveEntityHydrationPorts.cs:97`, body at +`src/AcDream.App/Rendering/Vfx/EntityEffectController.cs:199-205`). That queue is +written only by `HandleDirect`/`HandleTyped` +(`EntityEffectController.cs:92-126`) from inbound `PlayPhysicsScript` / +`PlayPhysicsScriptType` for that exact GUID. A fresh split GUID has nothing +queued unless ACE actually sent an effect for it — and draining it then is +retail's own behaviour, which the class documents and ports: +`SmartBox::HandlePlayScriptID @ 0x00452020` / `HandlePlayScriptType +@ 0x00452070` queue while the object is absent, and +`SmartBox::HandleCreateObject @ 0x00454C80` drains through +`ProcessObjectNetBlobs` (cited `EntityEffectController.cs:17-24, :37-45`). + +**The one plausible mechanism — a cloned default script — does not fire at +create in either client.** `BuildSpawn` does clone `DefaultScriptType` / +`DefaultScriptIntensity` from the source (they are not in the override list; +fields at `src/AcDream.Core.Net/Messages/PhysicsSpawnData.cs:58-59`), and they +reach `EntityEffectProfile.RawDefaultScriptType` +(`src/AcDream.App/Rendering/Vfx/EntityEffectProfile.cs:67`). But acdream's +`PlayDefault` (`EntityEffectController.cs:409-418`) has exactly two callers, +both animation hooks: `DefaultScriptHook` and `DefaultScriptPartHook` +(`:428-435`). Retail matches: `CPhysicsObj::play_default_script @ 0x005132B0` +/ `@ 0x00513300` is reached only from `ACCWeenieObject::DoCollision +@ 0x0058C3A0` (call @0x0058C3B4) and the animation-hook dispatcher +(@0x00526C08, @0x00526C14). **Neither client plays a default script from +`set_description` or CreateObject.** + +**Live evidence that would settle it if someone still doubts:** split a stack +of a WCID whose PhysicsDesc carries a nonzero `DefaultScriptType` and watch for +a script at the drop. Per the two call-site sets above there is none to +observe, so this is a confirmation, not an open question. + +## 6.5 Retail truth for drops and for "split-recovery marking" + +**Whole-item drop.** `ACCWeenieObject::UIAttemptPutIn3D @ 0x0058D700`: gate on +`IsPlayerReadyToMakeInventoryRequest` @0x0058D70F, send +`CM_Inventory::Event_DropItem` @0x0058D715, then record only +`prevRequest = IR_MOVE` (@0x0058D74F) or `IR_DROP` (@0x0058D731). **No +placement call, no split marker, no client-side position.** The dropped object +re-enters the world through the ordinary CreateObject path. + +**Split.** `ACCWeenieObject::UIAttemptSplitTo3D @ 0x0058D850` sends +`Event_StackableSplitTo3D` @0x0058D86B and then records exactly three fields: +`splitStackSize = arg2` @0x0058D8A2, `splitClassID = this->pwd._wcid` +@0x0058D8A8, `splitTime = cur_time` @0x0058D8AE. +`ACCWeenieObject::UIAttemptSplitToContainer @ 0x0058D7D0` records the +**identical three fields** at @0x0058D82D / @0x0058D833 / @0x0058D838. + +**The marker's consumer is `ACCWeenieObject::DeclareValid @ 0x0058E340`:** + +``` +splitClassID read @0x0058E44A +if (splitClassID != INVALID) { + if (splitClassID == this->pwd._wcid) @0x0058E462 + stack = this->pwd._stackSize; if (0) 1 @0x0058E464-@0x0058E46E + if (splitStackSize == stack) { @0x0058E479 + ACCWeenieObject::SetSelectedObject(this->id, 0); @0x0058E481 + splitClassID = INVALID; return; @0x0058E490 + } + if (cur_time - splitTime >= 10.0) @0x0058E49F-@0x0058E4B0 + splitClassID = INVALID; @0x0058E4B2 +} +``` + +**So retail's "split-recovery marking" is (i) a WCID + stack-size + 10-second +identity recorded for BOTH split flavors, and (ii) a recovery action that is a +SELECTION TRANSFER — `SetSelectedObject` — not effect suppression and not +anything placement-related.** + +acdream implements (i) partially and (ii) not at all: + +- The 10-second window is exact: `PendingSplitToWorldProjection.RetailRecognitionSeconds = 10.0` + (`InventoryWorldDropProjectionController.cs:113`), enforced `:146-153`. +- The match is **"any unknown GUID that is not the source"** (`:155-156`), not + WCID + stack size, because ACE's F748 carries neither. This is the + already-registered **AP-124** + (`docs/architecture/retail-divergence-register.md:274`), which cites both + retail symbols correctly. +- acdream records the marker only for `InventoryRequestKind.SplitToWorld` + (`:86`); retail records it for split-to-container too. +- No `SetSelectedObject` equivalent runs. The controller's constructor + (`:33-52`) takes interaction / objects / runtime / hydration / clock and has + no selection dependency at all. + +## 6.6 Route 6 — what remains + +**Nothing that C4 must do.** The three genuine residuals are: + +| # | Item | Kind | +|---|---|---| +| R6-a | `DeclareValid`'s `SetSelectedObject(this->id, 0)` @0x0058E481 is not ported — the split result does not become the selected object, and the container-split flavor has no marker at all | UI/selection behaviour, **not placement**; file as an issue, out of C4 scope | +| R6-b | AP-124's WCID/count approximation | already registered; retiring it needs ACE to send CreateObject to the initiator, not a client change | +| R6-c | `BuildSpawn` clones `Children`, `Movement`, `AnimationFrame`, `SetupTableId` etc. wholesale from the source | **not established** as a defect: a stackable inventory item's `Children` should be empty and its `Movement` inert. Settled by dumping a source spawn's `PhysicsSpawnData` at a live split — one `ACDREAM_DUMP_*`-style probe, or an assertion in the new tests | + +**Recommended route-6 landing: evidence + tests only, no production diff.** +Add the coverage the campaign handoff's route-6 test list names +(`2026-07-31-remaining-physics-campaign-handoff.md:363-365`) against the +now-flipped path: whole item, split stack, new GUID, second drop position, +unavailable destination, newer Position while waiting. Note that "attached +child becoming a world root" from that list is **not route 6's** — it is a +cell-less Position on an existing entity, owned by 4b-3. + +## 6.7 Route 6 — line budget + +- **Production: 0 lines** for the campaign mandate. If R6-a is adopted inside + C4 (I recommend against it — it is selection UX, and mixing it in makes the + landing un-reviewable against a placement contract), **40-80** lines. +- **Tests: 150-250 lines.** Route 4a was 364 production lines; this is an + order of magnitude below the threshold where splitting is a question. + +## 6.8 Route 6 — connected gate + +Cheap and directly visible. + +1. Stand on open ground in Holtburg. Drop a whole item from inventory (drag to + 3D or the drop action). It must appear **at your feet**, resting on the + ground, immediately, and be pickable. +2. Split a partial stack of a stackable (e.g. pyreals) to the ground. Same + result, with the correct quantity on the new pile and the remainder still + in inventory. +3. Drop a second item within ~1 m of the first. Both must remain visible and + separately pickable. +4. Repeat once indoors (an inn interior) and once immediately after a portal + recall. +5. Walk two landblocks away and back. Both piles must still be there and still + pickable. + +**Regressions to watch for:** the item appears at the world origin or at your +*previous* position (stale pose); the item is invisible but blocks you +(invisible-but-solid, the #184 class); the item sinks into or floats above the +floor; the item cannot be picked up; the split pile never appears at all +(the recovery window failed); the second drop is swallowed by the first. + +--- + +# ROUTE 7 — pickup, parent, and delete + +## 7.1 What the route actually is + +**Dispositions.** Route 7's classifier surface is +`RuntimeAuthoritativePositionRouteClassifier.ClassifyLeaveWorld` +(`:475-505`), taking a `RuntimeLeaveWorldRouteRequest` (`:141-145`) whose +`RuntimeLeaveWorldCause` is `Pickup` or `Parent` (`:31-36`). It returns +disposition `AwaitFreshPosition` with `LeaveWorld: true`, +`SetPositionFlags.None`, `ConstrainPhase.None`, and no teleport hook. + +The create-side siblings are `RuntimeCreateResidenceKind.Parented` and +`PickedUp`, which `ClassifyCreate` routes to the same `AwaitFreshPosition` / +no-SetPosition branch (`:219-241`), with the comment at `:230-233` stating why +initial residence invents no withdrawal edge. + +**`ClassifyLeaveWorld` has ZERO production callers.** Repo-wide grep returns +its own definition and exactly one test +(`tests/AcDream.Runtime.Tests/Physics/RuntimeAuthoritativePositionRouteClassifierTests.cs:341-342`), +plus a comment in `RuntimeInitialCreateContinuationExecutorTests.cs:1672` +noting that `ApplyPositionAction` does not call it. Every production pickup / +parent / delete performs its leave-world work by hard-coded sequence inside +`RuntimeEntityObjectLifetime`, never through the classifier. + +**Entity kind** is whatever the object is — `Remote` for items, and the +classifier's `OperationKind(kind, initialCreate: false)` derives from it. + +## 7.2 Retail truth: route 7 performs NO placement + +This is the single most important fact for scoping route 7, and it inverts the +trap list that governed routes 2 / 4a / 4b. + +**Pickup — `SmartBox::DoPickupEvent @ 0x00452240`:** + +``` +gate on obj->update_times[0] (the POSITION stamp) @0x0045224B-@0x00452274 + obj->update_times[0] = incoming @0x00452278 + CPhysicsObj::unset_parent(obj); @0x0045227F + CPhysicsObj::leave_world(obj); @0x00452286 +``` + +No `SetPosition`, no `MoveOrTeleport`, no `ConstrainTo`, no placement flags. + +**Parent — `SmartBox::DoParentEvent @ 0x00452290`:** + +``` +gate on child->update_times[0] @0x00452296-@0x004522C5 + if (child had no parent && parentId != player) + parentWeenie->SetParentedState(1); @0x004522F4 + CPhysicsObj::set_parent(child, parent, loc); @0x00452305 + CPhysicsObj::SetPlacementFrame(child, arg5, 1); @0x00452313 +``` + +**`CPhysicsObj::set_parent @ 0x00515A90` (3-arg):** + +``` +if (parent != 0 && add_child(parent, this, loc) != 0) { @0x00515A9D + unset_parent(this); @0x00515ABA + leave_world(this); @0x00515AC1 + this->parent = parent; @0x00515AC6 + if (parent->cell != 0) { @0x00515AD1 + change_cell(this, parent->cell); @0x00515AD6 + UpdateChild(parent, this, part_number, frame); @0x00515B0E + recalc_cross_cells(this); @0x00515B15 + } + if (parent->state & NODRAW-bit) { @0x00515B26 + this->state |= 0x20; + part_array->SetNoDrawInternal(1); @0x00515B38 + } + return 1; @0x00515B45 +} +return 0; @0x00515B4D +``` + +The 4-arg overload @0x00515B50 is the same shape, minus the `CHILDLIST` +index lookup and plus `m_bExaminationObject` inheritance @0x00515B76. + +**`CPhysicsObj::unset_parent @ 0x00513470`:** `CHILDLIST::remove_child` +@0x00513484 → NoDraw restore @0x00513495-@0x005134A7 → `parent = null` +@0x005134AC → `update_time = cur_time` @0x005134BF → tailcall +`clear_transient_states` @0x005134CE. + +**Conclusion.** Route 7's entire mandate in this campaign is the one the +campaign handoff already states +(`2026-07-31-remaining-physics-campaign-handoff.md:367-373`): pickup / parent / +delete must **cancel** the exact active placement / lost-cell family first and +publish `Discard`/`Withdraw` before the later entity delta. There is no +placement to port, no leash, no distance threshold, no flags. + +## 7.3 What is already canonical (verified, not assumed) + +**Every cancellation choke point exists and is live.** Six sites in +`src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs`, each running the +identical `ForgetInitialCreateResidence` → `Physics.SetPosition.Forget` → +`PreferCancellation` sequence and threading the receipt into +`AcknowledgeProjectionAndPublish`: + +| Method | Cancellation lines | +|---|---| +| `CommitPositionChannelUpdate` | `:1074-1094` | +| `TryApplyPickup` | `:1226-1245` | +| `TryCommitParent` | `:1360-1374` (added at C0; rationale `:1344-1359`) | +| `CommitAcceptedParentCellless` | `:1390-1407` | +| `CommitWithdrawal` | `:1845-1860` (added at C0; rationale `:1837-1844`) | +| `TryAcceptDelete` | `:1939-1952` | + +`ForgetInitialCreateResidence` is at `:2552-2569`; `PreferCancellation` +(initial wins over ordinary) at `:2571-2574`. + +**The two C0 asymmetries the inventory flagged (`:826-837`) are FIXED.** +`TryCommitParent` had no choke point at all; `CommitWithdrawal` cancelled only +the residence family. Both now cancel both families, and both carry the +explanatory comment naming the C0 finding. `TryCommitParent`'s deliberate +omission of `Physics.CollisionReports.LeaveWorld` is documented `:1352-1359` +against `set_parent @ 0x00515A90`'s single gated `leave_world` @0x00515AC1 — +**do not "fix" this; the campaign contract already pins it.** + +**Ordering is correct.** `AcknowledgeProjectionAndPublish` (`:2187-2213`) runs +`Physics.SetPosition.PublishCancellation(cancellation)` as its **first** +statement at `:2194`, before the currency re-check, before the host +acknowledgement callback, and before `PublishEntity`. That is exactly the +handoff's "publish `Discard`/`Withdraw` before the later entity/inventory +delta." + +**Cancellation receipts ARE host-visible.** +`RuntimeSetPositionState.PublishCancellation` (`:5296-5309`) republishes the +retained `Discard`-kind `RuntimePlacementProjectionSnapshot` through +`PublishPlacement`, i.e. through the same one placement stream. Both hosts +consume the kind: `src/AcDream.App/World/RuntimePlacementPresentationSink.cs:102`, +`src/AcDream.App/World/LiveEntityRuntime.cs:1179`, and +`src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs:27`. +**The C0 note "route-7 needs … host-visible cancellation receipts" is +satisfied.** + +**Pickup/parent during a pending residence already defer.** `TryApplyPickup` +`:1189-1215` enqueues a dormant `RuntimeInitialCreateContinuationKind.Pickup` +continuation when `TryGetPendingInitialResidence` hits; `TryApplyParent` and +`TryApplyMotion` have the same shape. + +**App and headless carry no duplicate placement authority for route 7.** +`LiveEntityDeletionController` (102 lines) is purely logical. +`LiveEntityHydrationController.OnPickup` (`:455-474`) calls +`_runtime.TryApplyPickup` then `_relationships.OnChildBecameUnparented`. +Headless `OnPickedUp` / `OnDeleted` / `OnParentUpdated` +(`RuntimeLiveEntitySessionController.cs:176-180, :155-174, :313-317`) are thin +pass-throughs. Grep for `SnapToCell` / `CommitRebucket` / `SuspendObjectClock` +/ `SetFullCell` across `src/AcDream.App` and `src/AcDream.Headless` returns no +pickup/parent/delete site. + +## 7.4 The one real defect — the child's canonical cell has two writers + +This is route 7's whole substance. + +**Retail** puts the child in the parent's cell **inside `set_parent`**: +`change_cell(this, parent->cell)` @0x00515AD6 followed by +`recalc_cross_cells(this)` @0x00515B15, gated on `parent->cell != 0` +@0x00515AD1. + +**acdream splits that in half across two assemblies:** + +1. Runtime commits the child **cell-less, unconditionally**. + `RuntimeEntityObjectLifetime.CommitAcceptedParentCellless` (`:1377-1408`) + runs `Physics.CollisionReports.LeaveWorld` `:1396`, + `Entities.SuspendObjectClock` `:1397`, `Entities.SetFullCell(canonical, 0u, + 0u)` `:1398`. Its App wrapper's doc comment + (`LiveEntityRuntime.cs:2288-2292`) claims it "commits retail + `CPhysicsObj::set_parent`'s cell-less edge" — accurate only for the + `parent->cell == 0` case; retail's `parent->cell != 0` case immediately + re-cells the child and acdream never performs that half in Runtime. +2. **App re-cells the child from a presentation tick.** + `EquippedChildRenderController.TickChild` `:405-408` calls + `_liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId)`, and the + public `LiveEntityRuntime.RebucketLiveEntity` (`:801-977`) is *not* + presentation-only — it writes the canonical cell through + `_entityObjects.CommitRebucket(record.Canonical, committedFullCell, + committedLandblock)` at `:904-907`. + +So the canonical `FullCellId` of every equipped child is produced by a +per-frame render-composition tick, not by the parent commit. Two consequences, +and they are the same bug seen from two sides: + +- **Headless leaves every parented child cell-less forever.** + `RuntimeLiveEntitySessionController.OnParentUpdated` (`:313-317`) calls only + `Entities.TryApplyParent`. Neither `TryCommitParent` nor + `CommitAcceptedParentCellless` has any headless caller — repo grep finds only + `EquippedChildRenderController.cs:869` and the App wrappers at + `LiveEntityRuntime.cs:2280-2312`. There is no headless + `EquippedChildRenderController`, so nothing ever supplies step 2. The + inventory called this a "headless-specific gap, pre-existing, adjacent" + (`:839-853`) — it is not adjacent, it is the *same* missing commit. +- **The two-store shape is exactly the failure class this campaign exists to + remove**, and it is the shape the trap list names ("two separate snapshot + stores for one piece of state"). + +**Fix shape:** move the parent-cell commit into `TryCommitParent` / +`CommitAcceptedParentCellless` (i.e. perform retail's `parent->cell != 0` half +in Runtime, gated the same way), and demote App's `TickChild` rebucket to +presentation-only — `RebucketLiveEntityPresentationOnly` +(`LiveEntityRuntime.cs:993-1050`) is the existing shape, added by C3c for +exactly this "Runtime already owns the canonical commit" case. Headless then +gets the correct child cell for free. + +## 7.5 The second item — `ClassifyLeaveWorld` is dead + +Either wire pickup/parent through it so route 7 stops being the one route with +hard-coded dispositions, or delete it and record why the hard-coded sequence is +the right shape. Leaving a classified disposition with one test and no +production caller is how routes 2 and 4 accumulated their divergences. + +I lean **wire it**, because the cause discriminator (`Pickup` vs `Parent`) +is the thing that decides whether `EndChildProjection` runs, and today that +decision is implicit in *which method the caller picked*. + +## 7.6 Route 7 — line budget + +| Piece | Production lines | +|---|---| +| Runtime child-cell commit in `TryCommitParent` / `CommitAcceptedParentCellless` (retail's `change_cell` + `recalc_cross_cells` half, gated on parent cell) | 120-180 | +| Demote `EquippedChildRenderController.TickChild`'s rebucket to presentation-only + the follow path that replaces it | 60-100 | +| Headless parent-realize drive (`OnParentUpdated` → the same commit) | 60-100 | +| Wire or delete `ClassifyLeaveWorld` | 60-150 | +| **Total** | **300-490** | + +Calibration: route 4a was 364 non-comment production lines, 4b-1 was 230 + a +57-line park fix. **Route 7 fits one slice and must not be split** — the child +cell commit and the App demotion are the two halves of one transfer and cannot +land separately without leaving the child cell-less in the interval. Test work +~400-700 lines. + +If the implementation crosses **550** production lines, stop and report; the +most likely cause would be discovering that the per-tick follow (7.8, T5) needs +its own Runtime mechanism, which is a second slice. + +## 7.7 Route 7 — connected gate + +Two clients. `+Acdream` plus a second character observing. + +1. **Equip / unequip cycle.** Wield a weapon, then a shield, then unwield both, + five times, while the observer watches. The weapon must appear in the hand, + at the hand, oriented with the hand, and disappear cleanly on unwield. +2. **Carry across a landblock boundary.** With the weapon equipped, run across + at least two landblock boundaries and back, both while the observer watches + and while observing the other character do it. +3. **Pickup.** Drop the weapon, pick it back up. It must leave the ground + (route 7's `leave_world`) and re-appear equipped or in inventory with no + ghost left on the ground. +4. **Delete under load.** Kill something and loot its equipped items. +5. **Reconnect.** Log out with equipment and back in; the equipment must + re-attach. +6. **Headless.** Run the headless bot with an equipped item and assert the + child's canonical `FullCellId` equals the parent's at a stable checkpoint — + this is the direct regression test for 7.4, and it fails **today**. + +**Regressions to watch for:** the equipped weapon draws at the world origin or +at its last ground position; the weapon is invisible while equipped; +**invisible-but-solid** — the weapon's collision remains at the drop site after +being picked up; the weapon is left behind when the wearer crosses a landblock +boundary (this is the R1/#184 class and is the specific risk of demoting the +tick rebucket); the child is culled when the parent is visible or vice versa; +a picked-up item still blocks movement. + +## 7.8 Traps + +Mapped to the shapes that already bit this campaign, plus route-7-specific +ones. + +**T1 — the inverse leash trap. Route 7 must NOT arm `ConstrainTo`.** +4b-2's contract correctly says "arm on refusal and rejection too" because +`MoveOrTeleport` returns 1 regardless and `HandleReceivedPosition` @0x00454254 +arms @0x00454272. **Route 7 never reaches `HandleReceivedPosition` at all** — +`DoPickupEvent` @0x00452240 and `DoParentEvent` @0x00452290 are separate wire +handlers. An implementer arriving from 4b-2 will carry the "always arm" rule +across. There is no leash on this route. + +**T2 — deleting the only handler for a rejected classification.** +`ClassifyLeaveWorld` returns `RejectedAuthority` when +`Cause is RuntimeLeaveWorldCause.Unknown` or `ValidCreateAuthority` fails +(`:481-487`). Retail's `DoPickupEvent` has **only** a stale-timestamp no-op — +there is no "rejected" outcome that skips `unset_parent`/`leave_world` once the +stamp is accepted. If route 7 wires the classifier and performs the leave-world +only on `Accepted`, every rejected classification silently drops a pickup. This +is 4b-2's `null`/`Rejected*` trap, one level up: state the policy explicitly. + +**T3 — `ValidCreateAuthority`'s teleport-equality predicate is the #307 +shape.** `ClassifyLeaveWorld` gates on `ValidCreateAuthority` (`:507-512`), +which requires `PreviousTeleportSequence == AcceptedTeleportSequence`. That is +exactly the predicate #307 (`19d95094`) had to fix on the live Position path, +where `PreviousTeleport` was always 0 and every previously-teleported entity +failed it. Before wiring T2's classifier, **verify first-hand** that the +pickup/parent authority actually populates `PreviousTeleportSequence`, and that +retail's own gate is the *position* stamp (`update_times[0]` @0x0045224B), not +a teleport stamp at all — the two may simply not correspond. + +**T4 — do not carry `MoveOrTeleport`'s guards onto this route.** AP-87's 4 m +`BodySnapThreshold` and `!willBeDrTicked` conditions and the 96 m +`MaxPhysicsDistance` are near/far *interpolation* decisions inside +`MoveOrTeleport @ 0x00516330`. Pickup and parent have no distance concept. +Importing any of them is the "carry a guard onto a branch that does not need +it" shape AP-87's own row and the 4b-2 contract both warn about. + +**T5 — demoting the tick rebucket can reproduce #184 / route-4a's R1.** +`EquippedChildRenderController.TickChild`'s rebucket (`:405-408`) runs on every +recomposition, and `LiveEntityRuntime.RebucketLiveEntity` is what moves the +GPU draw bucket, sets `IsSpatiallyVisible`, calls `RefreshPresentation`, and +publishes `ProjectionVisibilityChanged` (`:844-953`). Route 4a's R1 finding was +precisely that gating this call left a creature body-and-shadow-correct but +draw-bucket-stale — **invisible but solid**. The replacement must keep the +child following the parent across cell boundaries. +**Retail-side status: PARTIALLY ESTABLISHED.** `CPhysicsObj::UpdateChild +@ 0x00512D50` composes the frame and calls `CPhysicsObj::set_frame(child, …)` +@0x00512D8D; `set_frame @ 0x00514090` writes `m_position.frame` @0x005140E9, +pushes it to the part array @0x00514101, and cascades +`UpdateChildrenInternal` @0x00514108 — **it never touches `objcell_id` or +`cell`.** So retail's per-move child update is frame-only, and the child's cell +is established once at `set_parent`'s `change_cell` @0x00515AD6. **Whether a +parent crossing a cell re-cells its children is NOT established** from +`UpdateChild`/`set_frame`/`UpdateChildrenInternal` alone. Settle it before +demoting: read `CPhysicsObj::change_cell` / `CPhysicsObj::set_cell`'s child +handling, or take a cdb trace with breakpoints on +`CPhysicsObj::change_cell` and `CPhysicsObj::UpdateChildrenInternal` while a +retail character carrying an equipped weapon walks across a landblock boundary. + +**T6 — the pre-flight-guard trap probably does NOT apply, and saying so +matters.** 4b-1's finding was that a pre-flight service-window guard cannot see +conditions only Core can see (`AdjustToOutside` residency, +`ResultTouchesPrefix` over `QueriedCellIds`). Route 7's child-cell commit is +retail's `change_cell` — **no sweep, no `AdjustPosition`, no placement** — so +there is no deferrable Core condition to miss. Do not import 4b-1's +service-window machinery here. If an implementer finds a reason a child cell +commit *can* defer, that is a new finding and needs its own stop-and-report. + +**T7 — ordering divergence on pickup, significance NOT ESTABLISHED.** Retail +runs `unset_parent` @0x0045227F **before** `leave_world` @0x00452286. acdream +inverts it: `RuntimeEntityObjectLifetime.TryApplyPickup` runs +`Physics.CollisionReports.LeaveWorld` `:1229` → `SetFullCell(0,0)` `:1235` → +`ParentAttachments.EndChildProjection` `:1236`, and at the App layer +`LiveEntityHydrationController.OnPickup` (`:460-473`) runs `TryApplyPickup` +first and `OnChildBecameUnparented` second. The two operations touch different +structures (collision reporting vs the relation table), so I could not +construct a case where the order is observable — but I did not prove it inert. +Decide deliberately and record the decision; do not silently preserve the +inversion because "tests are green". + +**T8 — do not add a second `LeaveWorld` to `TryCommitParent`.** Already pinned +by the campaign plan and by the in-code comment at `:1352-1359`, but it is the +most natural-looking "asymmetry" a reviewer will flag, because every sibling +method calls `Physics.CollisionReports.LeaveWorld` and this one does not. +Retail's `set_parent` has exactly one `leave_world` @0x00515AC1, inside the +`add_child` success branch that acdream's staged/deferred realize sequence +represents. + +--- + +## 8. Coupling, merge/split, and order + +**Do they share files?** **No.** Route 6's surface is +`InventoryWorldDropProjectionController.cs`, `ItemInteractionController.cs`, and +(read-only) `LiveEntityHydrationController.OnCreate`. Route 7's surface is +`RuntimeEntityObjectLifetime.cs`, `EquippedChildRenderController.cs`, +`RuntimeLiveEntitySessionController.cs`, and +`RuntimeAuthoritativePositionRouteClassifier.cs`. The only shared *concept* is +that both funnel through `LiveEntityHydrationController`, and route 6 does not +modify it. + +**Can 6 land without 7?** Yes, trivially — route 6 has no production diff. + +**Should they be ONE slice?** **No, but not for the campaign's reason.** They +should be *two landings of very different kinds*: + +- **Route 6 is a closure, not a slice.** No production change; a short evidence + note plus the drop/split coverage tests. Landing it as a "slice" with the + full contract → implementer → dual-review discipline would be ceremony over + a zero-line diff. +- **Route 7 is one genuine slice** of ~300-490 production lines, and its two + halves (Runtime child-cell commit; App demotion) **must not** be split from + each other. + +**Recommended order: route 6 first, then route 7.** Not because of a +dependency — there is none — but because route 6's closure is what removes the +inventory's false "genuine new capability, not merely dormant" claim +(`:768-775`) from the campaign's live record. Leaving that claim standing means +route 7's implementer inherits a planning document that asserts an effect-replay +suppression signal must exist. Cost of doing 6 first: a few hours. + +**One planning-record correction to fold in when route 6 closes:** the campaign +plan's own pre-cutover gap list +(`docs/plans/2026-08-02-placement-cutover.md:98-100`) still says +"Route-6 split-recovery creates need an effect-replay suppression signal; +route-7 needs `TryCommitParent`/`CommitWithdrawal` cancellation-symmetry fixes +and host-visible cancellation receipts". The first clause is unsubstantiated +(§6.4) and the second and third were both closed at C0 (§7.3). The list should +say what actually remains: the child-cell two-writer split and the headless +parent-realize gap. + +--- + +## 9. What I could not establish + +1. **Whether retail re-cells a child when its parent crosses a cell** (T5). + `UpdateChild` @0x00512D50 → `set_frame` @0x00514090 is frame-only and never + writes `objcell_id`. `change_cell`/`set_cell`'s child handling was not read. + This is load-bearing for demoting App's per-tick rebucket, and it is the one + thing route 7's contract must nail down before implementation. +2. **Whether the pickup ordering inversion is observable** (T7). Different data + structures; no constructed failure case; not proven inert. +3. **Whether `BuildSpawn`'s wholesale clone of `Children` / `Movement` / + `AnimationFrame` can carry anything harmful onto a split result** (R6-c). + Expected empty for a stackable inventory item; not measured. +4. **Whether `ClassifyLeaveWorld`'s `ValidCreateAuthority` teleport-equality + gate is even meaningful for pickup** (T3). Retail's gate is the position + stamp @0x0045224B; the classifier's is a teleport-sequence equality + inherited from the create path. The two may not correspond at all, in which + case wiring the classifier unchanged would reject valid pickups the way + #307 rejected valid force corrections.