docs: scope C4 routes 6 and 7 — route 6 needs zero production lines
Scoped together because both concern item/container placement; they turn out to
share no production file, but the shared inventory premise had to be corrected
once rather than twice.
The governing correction: both routes' inventory sections rest on
"RegisterEntityWithInitialResidence is never called from any production path."
C3c (529e0e9d) made that false — LiveEntityRuntime.cs:544-548 and
RuntimeLiveEntitySessionController.cs:118-123. Every conclusion reasoning from
"nothing upstream to cancel" is obsolete.
Route 6 (drops) is COMPLETE. No route-6 disposition exists: a dropped item is
Remote + TopLevel -> SetPosition/Placement|Slide, which is route 1's
classification exactly, and both drop flavors already converge on
LiveEntityHydrationController.OnCreate (split-recovery at
InventoryWorldDropProjectionController.cs:66). BuildSpawn overrides every
positional field, so the stale-source-position requirement is met.
Route 6 also retires a false premise in this campaign's own plan
(2026-08-02-placement-cutover.md:98-100): "replay create-time effects" is NOT
established as a defect. acdream's only create-time replay is the F754/F755
queue keyed by GUID, which is retail's own HandleCreateObject @0x00454C80
behaviour. The one plausible mechanism — a cloned DefaultScriptType — never
fires at create in either client; retail's play_default_script is reached only
from DoCollision @0x0058C3A0 and the hook dispatcher @0x00526C08/@0x00526C14.
And retail's "split-recovery marking" is a SELECTION transfer, not effect
suppression: UIAttemptSplitTo3D @0x0058D850 records WCID/stack/time and
DeclareValid @0x0058E340 re-selects at @0x0058E481. That port is missing but is
selection UX, not placement — file it outside C4.
Route 7 is one slice, ~300-490 lines, and must not be split: the Runtime commit
and the App demotion are two halves of one transfer. Retail performs NO
placement here — DoPickupEvent @0x00452240 is unset_parent + leave_world;
DoParentEvent @0x00452290 is set_parent + SetPlacementFrame. No SetPosition, no
leash. So route 7 inverts 4b-2's rule: never arm ConstrainTo.
Route 7's real defect: the child's canonical cell has two writers. Retail
re-cells inside set_parent (change_cell @0x00515AD6 + recalc_cross_cells
@0x00515B15); acdream commits the child cell-less in Runtime and re-cells it
from a RENDER TICK (EquippedChildRenderController.TickChild:408 ->
RebucketLiveEntity). Headless has no such controller, so every headless
parented child stays cell-less forever — the headless gap and the two-writer
split are the same bug.
Load-bearing unestablished item, recorded rather than guessed: whether retail
re-cells children when the parent crosses a cell. UpdateChild @0x00512D50 ->
set_frame @0x00514090 is frame-only and never writes objcell_id;
change_cell/set_cell's child handling was not read. Settle that before
demoting App's rebucket — demoting it blind risks route 4a's R1 / #184
invisible-but-solid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
07e979393b
commit
11a8742884
1 changed files with 672 additions and 0 deletions
672
docs/research/2026-08-04-c4-routes-6-7-scoping.md
Normal file
672
docs/research/2026-08-04-c4-routes-6-7-scoping.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue