research: settle retail parent-cell propagation and scope C4 route 3

Two read-only research landings that unblock the last two C4 routes.

**Parent-cell propagation (unblocks route 7).** Retail DOES re-cell children
when the parent crosses a cell, recursively to unbounded depth.
SetPositionInternal @0x00515330 branches on `this->cell == curr_cell`
@0x0051536d; the changed branch reaches change_cell @0x00513390, whose
delegates leave_cell @0x00510f50 and enter_cell @0x00510ed0 self-recurse over
children and write the FULL identity (add_object @0x00510ee2, objcell_id
@0x00510f1e, part-array cell id @0x00510f2b, cell pointer @0x00510f35).
change_cell itself has no child loop — the recursion is in the delegates.

The clincher: update_object @0x00515d10 early-returns on `parent != 0`
@0x00515d40, so a child never runs its own physics tick and parent
propagation is the ONLY mechanism maintaining its cell.

The trap this retires: the depth-1 loop @0x0051539c-0x005153d8 is the
SAME-CELL fast path (objcell_id and part-array id only, deliberately not the
cell pointer), not the propagation. An implementer finding it first would
conclude "depth-1, id-only" and ship equipped items stranded at landblock
boundaries — the #184 class. Route 7's planned set_parent-only write would
have done exactly that.

Settled by READING, not by a debugger trace. The scoping had listed this as
needing live cdb evidence, but change_cell/set_cell's child handling had
simply never been read; the project's grep -> decompile -> verify order had
not been exhausted. One BN field-name gap was closed by walking
struct CPhysicsObj in the verbatim acclient.h, so no PE byte-decode was
needed either. A breakpoint set is recorded for optional confirmation only.

**Route 3 scoping (portal, the last route).** ~225-400 added non-comment
production lines, ONE slice, contingent on #280 splitting out. Portalling
works today; route 3 removes a duplicate authority
(LocalPlayerTeleportPlacement.Place), it does not fix a bug.

Eight dated-inventory claims are now false, the most consequential being "the
binding machinery is 100% dormant end to end" — the portal authority's
CONSUMPTION and validation side is live production code at three layers and
is exercised by every placement; only the PRODUCER adapter is missing. That
makes route 3 materially smaller than the campaign plan implies.

#280 SPLITS from route 3, definitively: it is a reveal-gate/prefetch-window
concern (WorldRevealReadinessBarrier's neighbourhood radius versus retail's
mid_radius, LScape::PreFetchCells @0x00505660 / SmartBox::SetRegion
@0x00453227), mechanically disjoint from the placement cutover — route 3
reads the ready predicate, #280 rewrites it. The campaign plan already
sequences #280 separately; only the session handoff said it "rides with"
route 3, and the plan is right.

Retail's local portal arrival is the GENERIC path for the third route running:
SmartBox::TeleportPlayer @0x00453910 is SetPositionSimple(player, dest, 1)
with flags 0x1012 — route 2's exact primitive — plus PlayerPositionUpdated.

Two rule inversions recorded so route 3's implementer cannot carry the wrong
rule forward from the routes just landed: route 2's "never re-arm the leash"
INVERTS here (the teleport branch arms ConstrainTo @0x0045418A and zeroes
velocity @0x004541B4), and 4b-3's hook-before-placement ordering INVERTS (the
local teleport_hook runs AFTER placement, from PlayerPositionUpdated
@0x004538AE). The classifier's dormant LocalPlayer-teleport route already
encodes both.

Two documentation defects found in passing and recorded, not fixed: the
2026-07-16 portal pseudocode attributes portal arrival to enter_world (that
is the login path), and a stale comment hides a live second writer — the
generic wire-pose write does run for the local player (AP-131/C5 scope).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-04 21:23:56 +02:00
parent cff52c44f4
commit ca96ea5e32
2 changed files with 1120 additions and 0 deletions

View file

@ -0,0 +1,600 @@
# C4 route 3 — portal / local-player placement: SCOPING (2026-08-04)
**This is scoping, not a pinned contract.** Verified at HEAD **`cff52c44`**, branch
`claude/acdream-physics-divergence-5aa784`, clean tree. Line numbers are
as-of-HEAD and will go stale; every citation also names the symbol — trust the
symbol (process rule 6).
**Route 3 is NOT a bug fix.** Portalling works today — `/ls`, spell recalls,
portal use, and admin teleports all place correctly and are user-accepted
behaviour. Route 3 removes a **duplicate placement authority**: the local
player's portal arrival is committed by two host-owned hand copies
(`LocalPlayerTeleportPlacement.Place` in App,
`ResynchronizeLocalPlayerForPortalArrival` in Headless) instead of the one
canonical Runtime SetPosition transaction every other C4 route now uses.
Nobody should read this document as "portals are broken".
Route 3 is the LAST C4 route. Routes 1 (C3a-c), 2 (`9966b531`), 4a, 4b-1/2/3,
5 (`36255af0`), and 6 (zero production lines, `1b484937`) are landed; route 7
is scoped with its research blocker resolved
(`2026-08-04-retail-child-cell-ownership.md`).
Inputs verified for this scoping: the campaign plan
(`docs/plans/2026-08-02-placement-cutover.md`), the 2026-08-02 route inventory
(**dated — §3 below lists what is now false**), the route-5 contract + its
round-3 reviews (the current contract standard), the 4b-3 contract's 13
invariants, `claude-memory/project_portal_space.md`, the J6.2/J6.3 closeouts,
the six process rules in `2026-08-04-session-handoff-c4-remaining.md`, and
direct reads of every file cited below plus the retail pseudo-C.
---
## 1. Headline findings
1. **The campaign plan overstates what is missing.** Plan line ~92-93 says
"`RuntimePortalPlacementAuthority` has zero producing call sites; the
adapter from `RuntimeWorldTransitState` does not exist." The first half is
still true — zero production producers, confirmed by exhaustive read of
every `Begin*`/`TryPrepareAndSubmit*` call site (§4). The second half is
now HALF-false: the **consumption/validation side of the adapter exists
and is LIVE production code**, exercised (with an empty portal) by every
placement in the game:
- `RuntimeWorldTransitState.IsCurrentPlacementAuthority`
(`RuntimeWorldTransitState.cs:258-275`) — generation + sequence + cell +
host-token + not-superseding check;
- `RuntimePlacementPresentationSink.TryApply`
(`src/AcDream.App/World/RuntimePlacementPresentationSink.cs:100-106`) —
every graphical `Place` receipt is already gated on it;
- `HeadlessRuntimePlacementProjectionSink.TryApply`
(`src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs:100-121`)
— the headless twin;
- `LiveEntityRuntime.IsValidPortalPlacementAuthority`
(`src/AcDream.App/World/LiveEntityRuntime.cs:1448-1457`) — token-shape
validation inside every projection-record lookup;
- `RuntimeSetPositionState.BeginAcceptedPlacementCore:1528-1534` — Begin
refuses a non-empty portal unless kind is `LocalAuthoritative` AND the
authority's destination cell equals the record's accepted-position
landblock;
- the portal authority is threaded through the whole pipeline already:
`RuntimeSetPositionCommand.Portal` (`:128`),
`RuntimeSetPositionMoverPreparation.Portal`
(`RuntimeSetPositionMoverPreparation.cs:121`, consistency-checked in
`TryBuild:146`), `Operation.Portal` (`:433`), and
`RuntimePlacementProjectionToken.Portal` (`:250`, a REQUIRED positional
on every projection token).
**What is actually missing is only the producer**: nothing constructs a
`RuntimePortalPlacementAuthority` with `Present: true` (grep: zero in
`src/`; three test constructors), and no portal arrival drives a Runtime
placement at all. That makes route 3 materially smaller than the plan's
phrasing implies.
2. **Retail's local portal arrival is the GENERIC path — the pattern from
routes 2 and 5 holds a third time** (§5). `SmartBox::TeleportPlayer`
@0x00453910 is two calls: `CPhysicsObj::SetPositionSimple(player, dest, 1)`
— the EXACT primitive route 2 already routed through Runtime's
`CommitCanonical` for ForcePosition, flags 0x1012 — and
`SmartBox::PlayerPositionUpdated(this, 1, FLT_MAX)`. There is no dedicated
local portal placement path to port; the work is wiring, ordering, and the
post-placement suffix.
3. **The classifier's LocalPlayer+TeleportAdvanced route is fully modeled and
steady-state dead** (`RuntimeAuthoritativePositionRouteClassifier.cs:349-368`):
`SetPositionSimple` + `AuthoritativeTeleportFlags` + hook
`AfterPositionOperation` + constrain `AfterPositionOperation` +
`ZeroVelocity: true` + `SendPositionImmediately: false` — matching the
retail listing exactly (§5). Its only production caller today is the
initial-create continuation executor inside the residence window; route 3
would be its first steady-state producer.
4. **#280 is mechanically distinct from route 3 and must SPLIT** (§7).
---
## 2. Site inventory at HEAD (re-verified, not inherited)
### 2.1 The duplicate authorities to replace (both hosts)
| # | site (symbol) | at HEAD | what it does |
|---|---|---|---|
| D1 | `LocalPlayerTeleportPlacement.Place` | `src/AcDream.App/Streaming/LocalPlayerTeleportController.cs:214-278` (class `:183-290`) | The graphical duplicate authority, intact and essentially as the 2026-08-02 inventory described plus the 2026-07-25 rebucket fix: `_physics.Resolve(pos, cell, 0, StepUpHeight)` `:219``controller.SetPosition` `:230` (which runs the whole teleport tail in `SetPositionCore`, see 2.4) → direct `entity.SetPosition/ParentCellId/Rotation` `:242-244``RebucketLiveEntity` (throws on failure) `:254``_host.Host?.NotifyTeleported()` `:264``SetBodyOrientation` `:265` → camera resets `:267-268``_spatial.Reconcile()` `:269`. No Runtime transaction, no receipt, no portal authority. |
| D2 | `HeadlessSessionWorldProjection.ResynchronizeLocalPlayerForPortalArrival` | `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs:788-836` | The headless duplicate authority — the old `SynchronizeLocalPlayer` body, SURVIVING ONLY FOR THIS ROUTE (its own doc at `:779-787` says "TODO-C4 (route 3)"): `CenterOn``Physics.Engine.Resolve(wire, lb, 0, 100f)` `:810``Engine.ResolvePlacement(0.48f/1.835f, IsPlayer\|EdgeSlide)` `:816``controller.SetPosition` `:831``SetBodyOrientation` `:835`. Called from `PrepareDestination:757-762`. |
### 2.2 The drive machinery (graphical)
| site | at HEAD | note |
|---|---|---|
| `LocalPlayerTeleportController` (drive) | `LocalPlayerTeleportController.cs:385-789` | `OnTeleportStarted:436-459` (F751 → `TryQueueTeleportStart`); `OfferDestination:461-470`; `Tick:472-570` — readiness = destination + `!IsRecenterPending` + `_worldReveal.Evaluate(cell).IsReady`; on `TeleportAnimEvent.Place`: `CanPlacePortalDestination` preflight `:514``_placement.Place` `:521``ObserveMaterialized` `:527` (**still a rubber-stamp AFTER the mutation** — the inventory's structural claim holds); `PlayExitSound``RevealWorldViewport`; `FireLoginComplete``EnterWorld` + `SendLoginComplete` + `Complete`. |
| destination offer | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:2950-2957` (was `:1906-1913`) | Last statement of `OnPosition`: `Apply` disposition + local guid → `_localPlayerTeleport.OfferDestination(FromAcceptedPosition(update), timestamps.TeleportAdvanced)`. No placement runs for a local Apply anywhere in `OnPosition` (route 2's ForcePosition drive at `:2014-2071` is disposition-exclusive with it). |
| `WorldRevealCoordinator` | `src/AcDream.App/Streaming/WorldRevealCoordinator.cs` (574 lines) | Holds the current `RuntimeWorldHostProjectionToken` PRIVATELY in `_hostProjections` (`:40-62`, `FindCurrentHostProjection:516`); `TryBeginPortal:117-149` mints generation + registers the host projection. **The producer needs read access to this token (or re-derives it via the transit's idempotent `TryRegisterHostProjection`)** — a small exposure decision for the contract. |
| local generic render-pose write | `LiveEntityNetworkUpdateController.cs:2284-2289` + `:2314` | **Live finding, verified against the code (the comment at `:2276` is stale):** `TryApplyGenericRemoteRenderPose(entity, null, …)` gate is `OwnsSteadyState(route)` (`:1037-1052`) which is FALSE for the local player's null route — so every accepted local Apply, including the portal DESTINATION Position, writes the raw wire pose onto the local player's `WorldEntity` and rebuckets to the wire landblock while portal space still covers the viewport. Pre-existing, hidden by the portal viewport, later overwritten by `Place`. AP-131/#275 territory — see trap T8. |
### 2.3 The headless portal flow
`RuntimeLiveEntitySessionController.TryCompletePortal`
(`src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs:339-428`),
called from `OnPositionUpdated:296` and `OnTeleportStarted:330`:
`TryGetAcceptedTeleportDestination``TryBeginPortalReveal`
`TryRegisterHostProjection` → ack `ProjectionRegistered`
**`PrepareDestination(generation, destination)`** `:368` (this is where D2
runs, plus `controller.State = PlayerState.InWorld` and the readiness report)
`AcknowledgeDestinationReadiness``AcknowledgePortalMaterialized` → ack
`SimulationReleaseProjected``RequireDestinationReservationRelease` → ack
`DestinationReservationReleased``AcknowledgeWorldViewportVisible` +
`Complete` → ack `TerminalProjected``SendGameAction(LoginComplete)`
`EndTeleport`. The J6 lifecycle itself is canonical and healthy; only the
placement inside `PrepareDestination` is the duplicate.
### 2.4 Runtime surfaces route 3 builds against
| site | at HEAD | relevance |
|---|---|---|
| `RuntimePortalPlacementAuthority` | `RuntimeSetPositionState.cs:105-119` | `(bool Present, long RevealGeneration, ushort TeleportSequence, RuntimeWorldHostProjectionToken Projection)`. **The shape is RIGHT for what transit holds** — it is exactly the tuple `RuntimeWorldTransitState` owns per reveal (`Snapshot.Generation`, `ActiveTeleportSequence`, the registered host token whose `DestinationCell` is validated everywhere). No shape change needed. |
| `RuntimeWorldTransitState` | `RuntimeWorldTransitState.cs` (1,012 lines) | Producer inputs all exist: `IsTeleportActive:96`, `ActiveTeleportSequence:97`, `TryGetAcceptedTeleportDestination:522`, `CanPlacePortalDestination:534` (read-only preflight), `TryRegisterHostProjection:189` (idempotent for the same generation/cell — a legitimate token re-derivation path), `AcknowledgePortalMaterialized:595`, `IsCurrentPlacementAuthority:258`. |
| `RuntimeAcceptedPositionDriveController` | `src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs` (924 lines) | Route 2's landed local-player accepted-Position seam over `TryBeginExclusiveAuthoredPlacement:389` + `TryPrepareAndSubmitAuthoredPlacement:677` (`resolveWorldOffsetFromRuntimeFrame: true`). **ForcePosition-only by gate** (`:340-360`); its `Pending`/ack funnel is ForcePosition-shaped (`PositionEventOwed`). The natural home for a sibling portal arm — but see trap T7 before inheriting any of its funnel. |
| `PlayerMovementController` teleport tail | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:1845-1920` (`SetPositionCore`) | Today's `controller.SetPosition` on the portal path performs, in one method: body snap, render-lerp anchor reset, `UpdateCellId` publication, Contact\|OnWalkable\|Active transient set, zero velocity, `StopCompletelyAtPhysicsObjectBoundary` (retail StopCompletely 0x00527E40), input-edge/mouse state reset, `UnStick` + `UnConstrain` + `RearmConstraintLeashAtCurrentPosition` (retail @0x0045418A analog), and `_objectClock.ResetForEnterWorld()`. Under a canonical commit the BODY write moves into Runtime's `CommitCanonical`; **every one of these controller-local duties must be re-homed into a teleport analog of `CommitCanonicalForcePositionFrame` (`:1944-1950`)** — losing any one of them is the 4b-2 round-1 defect class. This is the single largest and riskiest piece of the slice. |
| classifier LocalPlayer teleport branch | `RuntimeAuthoritativePositionRouteClassifier.cs:349-368` | See §1.3. Production classify callers: the drive controller `:922` (Force only), `ClassifyRemoteAcceptedPosition` (remote/projectile), the continuation executor `:1928` (residence window). |
| `SpawnPlacementSettler` | `src/AcDream.Core/Physics/SpawnPlacementSettler.cs:22`; local-player call site `RuntimeLocalPlayerPhysicsPublicationState.cs:812` | First-entry only (C3c-F5/AD-61). **Not invoked on the portal path today, and retail's portal arrival has no settle sweep either** — contact resolves inside `SetPosition`'s own transition (SLIDE flags). Do not add it (trap T10). |
| world frame | `RuntimePhysicsState.ObserveLocalWorldFrame:550-562`; callers `RuntimeEntityObjectLifetime.cs:1739/:1794` | Runtime's world frame rebases on `teleportAdvanced` at the accepted-Position merge; App's `LiveWorldOriginState` recenters asynchronously afterwards. See trap T4. |
### 2.5 What is already canonical and must not regress (J6)
Slice J6.2/J6.3/J6.4 ownership stands: `RuntimeWorldTransitState` owns the
reveal generation, destination latch, F751 correlation (both packet orders),
materialization/simulation edge, viewport observation, wait cue, completion/
cancellation, and the 4-stage host acknowledgement suffix. Route 3 must not
move ANY of that; it only makes the transit owner additionally gate the
placement mutation it currently rubber-stamps. The lifecycle gate's
`transitOwnership` zero-at-stable-checkpoint discipline
(`tools/run-connected-world-lifecycle-gate.ps1`) already covers this state.
---
## 3. The dated inventory: claims now false
The 2026-08-02 route inventory predates C3c, route 2, the four route-4
sub-slices, route 5, and the OnPosition collapse. **Eight of its claims
bearing on routes 3/8 are now materially false**, plus pervasive stale line
numbers:
1. *"The binding machinery is real but 100% dormant end to end."* — FALSE.
The validation half is live at three production layers (§1.1) and runs for
every placement (empty-portal case). Only the producer is dormant.
2. *"Neither host has ever constructed a production `IRuntimePlacementObserver`
implementation"* (prerequisite D "100% unbuilt on the host side") — FALSE.
`RuntimePlacementPresentationSink` (graphical) and
`HeadlessRuntimePlacementProjectionSink` (headless, wired at
`HeadlessSessionHost.cs:701`) are production consumers of the placement
FIFO.
3. *"`BeginAcceptedPlacement`/`BeginAuthoredPlacement` … zero external callers
repo-wide"* / *"a placement transaction … never begun anywhere in
production"* — FALSE. Three production controllers drive
`TryBeginExclusiveAuthoredPlacement` + `TryPrepareAndSubmitAuthoredPlacement`
(local force, remote, first-entry). (The two named legacy wrappers
`BeginAcceptedPlacement`/`BeginAuthoredPlacement` themselves are now
test-only entry points.)
4. *"`InboundPhysicsStateController.TryApplyPosition` is today's only
PRODUCTION Position wire caller; the classifier-based path is test-only"*
— FALSE since routes 2/4a/4b-2/4b-3/5.
5. The entire route-2 chain (`LocalForcePositionTransaction`, pre-commit ack,
generic-tail double write for ForcePosition) — GONE; route 2 landed.
6. Headless route-8: *"`SynchronizeLocalPlayer` (566-615) is the duplicate
placement authority … `CreateController` (639-655) constructs
`PlayerMovementController`"* — FALSE at HEAD. C3c deleted the route-1/8
initial-entry hand-copy and the controller construction; the ONLY
surviving headless duplicate is the renamed portal-arrival re-resolve (D2).
7. *"`BlipLocalPlayer` (617-637) direct blip"* — FALSE; deleted by route 2
(halves split into `CenterOnAcceptedForcePosition` + the drive controller).
8. *"The executor publishes only generic entity deltas; nothing bridges its
completion to `RuntimePlacementProjectionChannel`"* — FALSE since C0/C3-1
(`ExecutorCompleted` receipts; `TryGetInitialCreateCompletion`).
Still TRUE from the inventory's route-3 section: the call-chain shape
(F751 → queue → offer → aim → `TryBeginPortal` → Place at anim event), the
`Place`-mutates-then-`ObserveMaterialized`-rubber-stamps sequencing, the
"discard/cancellation semantics structurally sufficient but UNEXERCISED for a
real placement" warning (now with live validators, still zero portal-carrying
operations — the explicit cutover tests it demanded are still owed), and the
executor scope note (portal arrival is a steady-state placement, not a
Create admission — the FIFO executor is not the target).
Stale-but-substantively-true: the offer site (`:1906``:2950`), the
validator (`:1162``:1448`), `PrepareDestination` (`:539``:752`,
re-resolve retained under a new name), `RuntimeWorldTransitState` still 1,012
lines.
---
## 4. The missing producer, precisely
What route 3 must build (and ALL it must build on the authority side):
1. **The producer**: at the placement moment, construct
`new RuntimePortalPlacementAuthority(Present: true, RevealGeneration,
ActiveTeleportSequence, hostProjectionToken)` from the live transit state.
Graphical: the generation is already at hand
(`_pendingRevealGeneration`, `LocalPlayerTeleportController:404/694`), the
sequence is `_transit.ActiveTeleportSequence`, and the host token needs a
small accessor on `WorldRevealCoordinator` (or re-derivation via the
idempotent `TryRegisterHostProjection`). Headless: all three are in scope
inside `TryCompletePortal` (generation, `destination.TeleportSequence`,
`projection`).
2. **The placement call**: a portal arm on the local-player drive seam that
runs `TryBeginExclusiveAuthoredPlacement(record, version,
LocalAuthoritative, portal)` + `TryPrepareAndSubmitAuthoredPlacement(…,
portal, resolveWorldOffsetFromRuntimeFrame: true)` with the classifier's
LocalPlayer-teleport route, invoked at each host's existing placement edge
(graphical: the `TeleportAnimEvent.Place` handler after
`CanPlacePortalDestination`; headless: `PrepareDestination`).
3. **The controller-local teleport frame commit** (§2.4's `SetPositionCore`
re-homing) invoked on the committed receipt.
4. **Re-sequencing**: `AcknowledgePortalMaterialized` fires from the committed
placement receipt (it then actually gates — `IsCurrentPlacementAuthority`
requires the materialization state it sets), camera reset +
`_spatial.Reconcile()` become acknowledge-edge reactions, and D1/D2's
direct mutations are deleted.
**The authority's shape does not need changing** (§2.4). One genuine edge to
design for: `BeginAcceptedPlacementCore:1528-1534` validates the authority's
destination cell against `record.Snapshot.Physics?.Position ??
record.Snapshot.Position` — the LATEST merged Position — while transit pins
the FIRST accepted destination per generation (J6.3). A second local Position
merging between the offer and the Place edge would make Begin refuse
(default token). Today that window is benign (ACE sends one destination per
teleport), but the refusal path must be defined and tested rather than
discovered: on refusal the transit generation must be cancelled or the
placement re-aimed — never a silent wedge in portal space.
---
## 5. Retail ground truth (verified in `acclient_2013_pseudo_c.txt` for this scoping; verify again at contract time)
| claim | address | status |
|---|---|---|
| F751 (`SmartBox::HandlePlayerTeleport`) writes exactly three flags — `position_update_complete = 0`, `has_been_teleported = 0`, `waiting_for_teleport = 1` — after a wrap-safe TELEPORT_TS check. No position, no cell, no physics. | @0x00452150, writes @0x00452193-0x004521A7 | ✓ read |
| `HandleReceivedPosition`'s local/non-local split is `arg2 != this->player` @0x0045414D. The LOCAL teleport branch (`newer_event(TELEPORT_TS)` @0x0045415F): `TeleportPlayer` @0x00454168**`ConstrainTo(player, &var_48 /* the WIRE destination */, start, max)` @0x0045418A** → **`set_velocity(player, 0, 1)` @0x004541B4** → return. | @0x00453FD0 | ✓ read |
| `SmartBox::TeleportPlayer` is TWO calls: `CPhysicsObj::SetPositionSimple(player, dest, 1)` @0x00453924 and `PlayerPositionUpdated(this, 1, FLT_MAX)` @0x00453932. **The generic placement primitive — the same one route 2 ported** (`BlipPlayer` @0x00453940 is the identical shape with arg 0). | @0x00453910 | ✓ read |
| `SetPositionSimple(…, 1)` builds flags **0x1012** = SEND_POSITION_EVENT \| SLIDE \| TELEPORT (`acclient.h:6123`) → generic `CPhysicsObj::SetPosition` @0x005160C0. Identical flags to the remote teleport branch. SLIDE is consumed in `CheckPositionInternal` @0x00511E90 (accept what find_valid_position resolved). | @0x005162B0 (0x1012 @0x005162C2) | ✓ read |
| `PlayerPositionUpdated` teleport arm (AFTER the placement): `position_update_complete = 0`, **`waiting_for_teleport = 0` (the F751 wait ends at PLACEMENT, not at reveal)**, `has_been_teleported = 0`, **`teleport_hook(player)`** @0x004538AE, `cmdinterp->PlayerTeleported()` (= `SetAutoRun(0,1)` + `SendMovementEvent`, @0x006B32B0), `set_viewer(&player->m_position, 1)` (camera reset, reset_sought), `LScape::update_viewpoint(0)` (destroys the terrain draw list), `CellManager::ChangePosition(&m_position, 1 /* blocking */)`. | @0x00453870 | ✓ read |
| **The hook ordering FLIPS versus the remote route**: local runs `teleport_hook` @0x00514ED0 AFTER `SetPositionSimple` returns (from `PlayerPositionUpdated`); the remote branch runs it BEFORE `SetPosition` (@0x005163EF). The hook `UnConstrain`s @0x00514F0C, and `HandleReceivedPosition` re-arms `ConstrainTo` @0x0045418A after `TeleportPlayer` returns, so the leash survives. The classifier already encodes this (`TeleportHookPhase.AfterPositionOperation` local vs `BeforePositionOperation` remote). | | ✓ read |
| **`CPhysicsObj::enter_world` @0x00516170 is NOT on the portal path.** Its local-player caller is the initial-login path only (@0x00455095, with `store_position` + blocking `ChangePosition`). Portal arrival never re-runs `enter_world` or `CPartArray::HandleEnterWorld`/`MovementManager::HandleEnterWorld` — the cell install happens inside `SetPosition` itself. **`2026-07-16-portal-completion-pseudocode.md` §2.1 attributes portal arrival to `enter_world` — that is the login path and the doc needs a correction note** (its conclusion about committing the cell before releasing simulation survives; the cited mechanism is wrong). | callers of 0x00516170 | ✓ read (agent-verified caller sweep) |
| The FORCE_POSITION branch (@0x0045400C`BlipPlayer``SendPositionEvent` → return @0x0045409D) never reaches any `ConstrainTo` — route 2's finding reconfirmed. The local TELEPORT branch DOES arm, wire-anchored, and DOES zero velocity — **the inversion trap T1**. | | ✓ read |
| Prefetch (#280's oracle, re-verified): blocking `CellManager::ChangePosition` @0x004559B0 releases the old landscape and calls `CellManager::PreFetchCells` @0x00455820 → outdoor `LScape::PreFetchCells` @0x00505660 walking **±`mid_radius`** landblocks (DAT-record residency, not GPU upload); indoor `CEnvCell::PreFetchCells`. `mid_radius` = `Render::m_RenderPrefs.LandscapeDrawDistance` via `SmartBox::SetRegion` @0x00453227 / `set_mid_radius` @0x00453180**the prefetch window IS the landscape draw distance, centred on the destination**. While `blocking_for_cells`, `SmartBox::UseTime` @0x00455410 runs ONLY `CheckPrefetchStatus`; object/physics/landscape/game-time/ambient all hold. Reveal (SmartBox::Show) is a further TunnelFadeOut second after the readiness edge; LoginComplete a WorldFadeIn second after that. | | ✓ read |
| Retail places IMMEDIATELY on the accepted destination Position and blocks SIMULATION on prefetch behind the portal viewport. acdream inverts this: it holds the PLACEMENT until reveal-readiness (`ready` in `LocalPlayerTeleportController.Tick`) and then places. This is the accepted portal-presentation architecture (J6/E5, user-gated repeatedly) — **route 3 keeps the deferred-Place timing and changes only WHO commits it** (trap T3). | | ✓ (architecture fact) |
Unverified residuals from the retail pass (mark for the contract, none
load-bearing for scope): the exact second argument of `teleport_hook` at
@0x004538AE (decompiler-elided, almost certainly 1); `CheckPrefetchStatus`'s
apparent 5.0 s re-check throttle (@0x00455BEE — suspicious, byte-verify if
#280 uses it); where SEND_POSITION_EVENT/TELEPORT flag bits are consumed
below `SetPositionInternal` (not traced; irrelevant to route 3's seam
choice).
---
## 6. Shape sketch (scoping altitude — the contract pins the real design)
- **Runtime**: a portal arm on `RuntimeAcceptedPositionDriveController` (a
sibling entry point, e.g. `TryExecuteAcceptedPortalArrival(record, route,
authority)`), sharing Begin/Submit/status handling with the force arm but
NOT its `PositionEventOwed`/re-issue funnel (trap T7). It consumes the
classifier's LocalPlayer-teleport route, applies `ZeroVelocity`, invokes
the controller-local teleport frame commit on the committed receipt, and
arms the leash post-operation (the route's `ConstrainPhase` — today's
`SetPositionCore` re-arm relocates here).
- **Graphical**: `LocalPlayerTeleportPlacement.Place` becomes
acknowledge-only — build the authority, call the Runtime arm, and on the
committed receipt run the presentation suffix (camera resets,
`NotifyTeleported`, `_spatial.Reconcile()`), then
`ObserveMaterialized`. The direct Resolve/SetPosition/entity-write/rebucket
bodies are deleted; the render entity moves via the existing
`RuntimePlacementPresentationSink` Place projection (whose portal gate
finally goes live).
- **Headless**: `PrepareDestination` keeps `CenterOn` + readiness reporting +
state writes; `ResynchronizeLocalPlayerForPortalArrival` is deleted and the
same Runtime arm is driven from the portal completion flow.
- **Probe**: `ACDREAM_PROBE_LOCAL_TELEPORT=1` (PhysicsDiagnostics-owned,
TEMPORARY family) — one line per portal arrival with cause
(portal/recall), placement status, portal generation/sequence,
destination cell, and hook/leash confirmation.
Register bookkeeping to expect (contract finalizes): AD-42's headless
portal-arrival-resync citation dies with D2; locate-or-add the row recording
the deferred-place timing adaptation (§5 last row — **UNVERIFIED whether an
existing AD row states it**; if none exists the route-3 commit must add it —
a deviation without a row is a bug twice over); the 2026-07-16 pseudocode
correction note (§5); the stale `:2276` comment (§2.2) corrected or the
finding filed.
---
## 7. #280 — definitive recommendation: SPLIT (do not bundle into route 3)
**The campaign plan and the session handoff disagree, and the plan is
right.** The plan lists #280 as its own numbered work item (item 3 of the
remaining campaign work), separate from "finish C4's routes 2-7" (item 2).
Only the 2026-08-04 session handoff says "#280 rides with [route 3]". They
cannot both be followed; this scoping recommends the plan's sequencing.
Why they are mechanically distinct:
- **Route 3 changes WHO commits the placement.** Its blast radius is
`RuntimeSetPositionState`/drive-controller/`PlayerMovementController`/the
two host placement bodies. It does not touch readiness evaluation at all —
the `ready` predicate in `LocalPlayerTeleportController.Tick:489-493` and
everything behind `WorldRevealCoordinator.Evaluate` is read, not written.
- **#280 changes WHAT the reveal waits for.** Its fix shape (per its own
ISSUES entry, whose retail oracle this scoping re-verified §5): replace
`WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius = 1`
(`src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs:38`) with a
retail-derived, quality-configured destination prefetch window
(`mid_radius` == landscape draw distance) and hold one generation-scoped
reservation across terrain/statics/EnvCells/render
publication/composites/collision until the complete visible window is
ready. Its blast radius is streaming/reveal/render-resource scheduling —
`WorldRevealReadinessBarrier`, `GpuWorldState` readiness joins, the
streaming reservation schedulers. It does not touch placement.
- The ONLY shared surface is the transit lifecycle both sit inside — and J6
already owns that; neither change moves it.
- Bundling would put a placement-authority cutover (physics-review skillset,
connected teleport gate) and a streaming-window change (render/streaming
skillset, view-distance visual gate at multiple quality settings) in one
diff, and would push the slice far past the ~500-line budget (#280 alone
plausibly rivals route 3 in size). 4b-2's process lesson ("split on
discovery; a defect outside scope rides for three review rounds") applies
prospectively here.
Sequencing: #280 can land before or after route 3 with no dependency in
either direction — route 3 keeps the Place gated on the SAME `ready`
predicate, whatever radius that predicate uses. Recommend: route 3 first
(closes C4), #280 immediately after as its own slice with its own visual
gate, before C5's closeout matrix (the campaign plan already sequences it
exactly there).
One honest caveat: bundling advocates would note both changes gate "what
happens at the Place edge" and a single connected session could gate both.
True but insufficient — a shared test session is not a shared mechanism, and
the review lenses are disjoint.
---
## 8. Size estimate and split recommendation
Calibration: route 4a 364; 4b-1 230+57; 4b-2 350-500; 4b-3 ~250 net new
(400-700 budget) with 739 deleted; route 5 ~131 added, ~180 deleted.
| piece | est. non-comment production lines |
|---|---|
| Runtime portal arm on the drive controller (Begin/Submit/status/receipt handling, ZeroVelocity, leash arm) | 90-150 |
| `PlayerMovementController` teleport frame commit (re-home the `SetPositionCore` suffix; split shared private helpers) | 40-80 |
| Graphical producer + `Place` rewrite + `ObserveMaterialized`/camera/reconcile re-sequencing | 60-110 (deletes ~55) |
| `WorldRevealCoordinator` host-token exposure | 10-20 |
| Headless: `PrepareDestination` flip + drive call | 15-30 (deletes ~50 incl. D2) |
| Probe line | ~10 |
| **Total added** | **~225-400** |
Net roughly +150 to +250 (deletions ~105-120). Tests are the larger share, an
estimated 500-900 lines (per-outcome placement tests on the portal arm,
supersession/cancellation/stale-generation discard tests — the inventory's
still-owed "explicit cutover test" for the discard semantics — presentation
assertions at #312's layer, dual-host parity, the Begin cell-mismatch edge of
§4).
**Recommendation: ONE slice, no split** — comfortably under the ~500
threshold PROVIDED #280 splits out (§7). Stop-and-report conditions for the
implementer: added production lines exceed ~500; the design starts needing
changes to `RuntimeWorldTransitState`'s lifecycle semantics, the
`TeleportAnimSequencer` timings, or the readiness barrier (each is a sign
#280 or a J6 regression is being smuggled in); or the Begin cell-mismatch
edge (§4) turns out to be reachable in ordinary play.
Allocation: a portal placement is once-per-teleport, not frame-frequency —
the C2 1,536 B budget is not an activation blocker here (route 2's
reasoning applies). Do not spend lines pooling.
---
## 9. The connected gate (real and mandatory — unlike route 5)
Portals are trivially reachable and user-visible; a live gate exists and is
required. **A clean-looking session is NOT a pass** (process rule 5; 4b-3's
gate was a pass only because 16 probe lines proved the arm executed, and its
cell-less half is still honestly recorded as unverified).
**Recipe (user-run, graphical):** Release build, `ACDREAM_RETAIL_UI=1`,
`ACDREAM_PROBE_LOCAL_TELEPORT=1`, live ACE. Exercise, in one session:
1. a physical portal (e.g. Holtburg portal) — outdoor destination;
2. a dungeon portal (indoor destination, EnvCell readiness path);
3. `/ls` lifestone recall and one spell recall (the F751 recall family);
4. an ACE admin teleport of the LOCAL player (`@teleto`/`@teleloc`) — this
advances ObjectTeleport and, per the route-2 visual-gate doc, exercises
exactly this route;
5. a same-destination revisit (ACE may omit CreateObject on revisit);
6. graceful close.
**Pass requires ALL of:**
- one probe line per arrival showing the CANONICAL path executed:
`placement=Committed`, the portal authority's generation/sequence, and the
resolved destination cell — zero arrivals through the deleted path (which
no longer exists to run) and zero `Refused/Contention` lines in ordinary
play;
- user visual: purple materialization silhouette without an opaque pop or
late tail (the 2026-07-25 accepted baseline must not regress), camera
reset behind the player at arrival, movement works immediately (walk out
with W held — the input-edge reset half of the teleport frame commit),
idle stance not a run-in-place, **no rubber-band or tether after arrival
AND no leash absence** (the leash IS armed on this route — a creature-free
way to observe it is beyond a visual check, so add a probe field
`leash=armed` instead of inventing a visual);
- the exact lifecycle/reconnect gate
(`tools/run-connected-world-lifecycle-gate.ps1`) passes with every
`transitOwnership` counter zero at every stable checkpoint — this is the
supersession/cancellation convergence evidence;
- **headless parity**: the K-style connected portal route (four-stop portal
routing from the K3 closeout) with the probe enabled — same
probe-line-per-arrival requirement, proving D2's replacement executed on
the no-window host.
**Known gap to state up front, 4b-3-style:** mid-transit supersession (a
second teleport starting before the first materializes) and mid-transit
disconnect are hard to provoke against ACE on demand. If the session does
not produce them, record the stale-generation discard behaviour as
test-verified-only — do not fold it into a blanket "gate passed".
---
## 10. Traps — rules from earlier routes that flip or do not transfer
- **T1 — route 2's leash rule INVERTS.** Route 2 pinned "the constraint
leash is NOT re-armed" because the FORCE_POSITION branch returns at
@0x0045409D before every `ConstrainTo`. The local TELEPORT branch is the
opposite: retail arms `ConstrainTo` @0x0045418A (anchored at the received
destination) AND zeroes velocity @0x004541B4. The classifier already
carries both (`ConstrainPhase.AfterPositionOperation`,
`ZeroVelocity: true`); the drive-controller doc comment
(`RuntimeAcceptedPositionDriveController.cs:127-132`) explaining why the
force arm never arms MUST NOT be generalized to the portal arm. Also
inverted: `SendPositionImmediately` is FALSE (no `AutonomousPosition` ack
on this route — retail's teleport branch returns without
`SendPositionEvent`; the outbound tail is LoginComplete, which stays where
it is, see T11) and `PreserveHeading` is FALSE (the wire orientation
applies).
- **T2 — 4b-3's hook ordering INVERTS.** Remote: `teleport_hook` BEFORE the
placement (@0x005163EF). Local: the hook runs AFTER `SetPositionSimple`,
from `PlayerPositionUpdated` @0x004538AE. The classifier encodes the flip
(`AfterPositionOperation` vs `BeforePositionOperation`). Copying 4b-3's
hook-first arm shape onto the local route would be retail-wrong. Also: the
local "hook" is not `RemoteTeleportHook` — its live actions for the local
player are the controller-local UnStick/UnConstrain (+ re-arm afterwards),
`NotifyTeleported` (TargetManager pair), the autorun cancel
(`PlayerTeleported``SetAutoRun(0,1)` — verify against the J5.4 autorun
latch at contract time), and `report_collision_end`.
- **T3 — do NOT move the placement to packet-accept time.** Retail places
immediately and blocks simulation on DAT prefetch; acdream's accepted
portal architecture defers the placement to the reveal-ready Place edge
behind the portal viewport (user-gated repeatedly; J6/E5). Route 3 changes
the executor of the Place, never its timing. An implementer "fixing" the
timing toward retail would regress the whole accepted presentation and
collide with #280's territory. Locate-or-add the register row for this
adaptation (§6).
- **T4 — two world frames disagree during the teleport window (#283).**
Runtime's frame rebases at the accepted teleport Position's merge
(`ObserveLocalWorldFrame(…, teleportAdvanced: true)`); App's
`LiveWorldOriginState` recenters asynchronously. Today `Place` runs only
after `!IsRecenterPending` (inside `ready`), so both frames agree at
placement time. The canonical arm must keep that gating and must resolve
through Runtime's frame (`resolveWorldOffsetFromRuntimeFrame: true`, as
route 2 does) — the App-translated `_pendingPosition` vector dies with D1.
A placement submitted during the disagreement window is the failure mode.
- **T5 — the Begin cell-mismatch edge** (§4): transit pins the FIRST
accepted destination per generation; Begin validates against the LATEST
merged snapshot. Define and test the refusal path; never wedge in portal
space.
- **T6 — hands off J6 and the presentation.** `RuntimeWorldTransitState`'s
lifecycle semantics, the 4-stage host acknowledgement, the
`TeleportAnimSequencer` seven-state machine, AD-38's viewport-retire
timing, the wait cue, and `TeleportViewPlaneController` are all
user-accepted and out of scope. The ONLY transit-adjacent change is that
`AcknowledgePortalMaterialized` fires from the commit receipt instead of
rubber-stamping after a host mutation.
- **T7 — do not inherit the force arm's funnel.** `PositionEventOwed`, the
`_newestForce` re-issue observation (AD-62), and `SendImmediatePosition`
are ForcePosition-shaped: ACE never repeats a portal destination, the
portal route owes no AutonomousPosition ack, and a dead-watch re-issue of
a portal placement would fight the transit generation. The portal arm
shares Begin/Submit/receipt handling only. (The mirror of 4b's "do NOT
port route 2's re-issue funnel" lesson.)
- **T8 — the local generic render-pose write is a pre-existing second
writer; do not silently delete OR silently keep it.** Every accepted local
Apply — including the portal destination Position — writes the raw wire
pose to the local player's `WorldEntity` and rebuckets to the wire
landblock (`:2284-2289`/`:2314`; the `:2276` comment claiming otherwise is
stale). Hidden by the portal viewport today and overwritten by Place. It
is AP-131/#275 (the ordinary local Apply path no route owns) — route 3
must TOLERATE it (the canonical projection overwrites on commit) and the
contract must decide explicitly whether the teleport window suppresses it;
whichever way, correct the stale comment (process rule 6).
- **T9 — no allocation work.** Once-per-teleport; the C2 budget is not in
play.
- **T10 — no settle sweep.** `SpawnPlacementSettler` is first-entry-only
(C3c-F5); retail's portal arrival has none — contact resolves inside the
SLIDE placement itself. If arrival contact looks wrong in the gate, the
bug is in the placement flags/transition, not a missing settle.
- **T11 — LoginComplete does not move.** It stays on
`TeleportAnimEvent.FireLoginComplete` (retail: ~2 s after reveal; TS-28 is
narrowed to initial login only). C3c's `175ad6b0` moved LOGIN's
LoginComplete to the first-placement terminal edge — that reasoning does
NOT transfer to the portal route.
- **T12 — headless keeps its non-placement duties.** `PrepareDestination`'s
`CenterOn`, readiness report, and `PlayerState` writes stay host-owned;
only the D2 re-resolve body flips. `BeginTeleport`'s direct
`PlayerState.PortalSpace` write is out of scope.
---
## 11. What route 3 does NOT do / what remains for C5
- **#280** — split out (§7); its own slice with its own visual gate.
- **AP-131 / #275** — the shared merge call and the ordinary local Apply
path (including T8's generic write) stay for C5.
- **AP-1/AD-1 retirement, the legacy-deletion sweep, parity tests, the
final connected matrix** — C5. Route 3's deletions are only D1/D2's bodies
and their direct wiring; any surviving dead seams
(`ILocalPlayerTeleportPlacement` if it reduces to a trivial adapter,
legacy `BeginAcceptedPlacement`/`BeginAuthoredPlacement` test-only
wrappers) are C5 sweep candidates, recorded not deleted here.
- **Route 7** (pickup/parent/delete) — separate, already scoped.
- **No headless remote consumer, no reveal-gate changes, no presentation/
anim/viewport changes, no `enter_world`/`HandleEnterWorld` additions**
(retail does not run them on this path — §5).
- The six `LiveEntityRuntimeTests` fixture failures named in the campaign
handoff and the final-binary soak remain campaign-level obligations, not
route-3 scope.
## 12. Open gaps (each with the check that closes it)
1. **The deferred-place register row** — grep the divergence register for
the portal place-at-readiness adaptation; if absent, the route-3 commit
adds it (T3).
2. **The autorun cancel** — confirm acdream's arrival path cancels autorun
(retail `PlayerTeleported``SetAutoRun(0,1)`): read the J5.4
`RuntimeLocalPlayerMovementState` autorun latch against
`SetPositionCore`'s input reset.
3. **`WorldRevealCoordinator` token exposure shape** — accessor vs
re-registration; decide at contract with a test that a superseded token
can never be handed to the producer.
4. **The Begin cell-mismatch refusal path** (§4/T5) — design + test.
5. **Retail residuals** from §5's unverified list (teleport_hook arg,
prefetch throttle) — byte-verify only if the contract leans on them.
6. **`RuntimePlacementPresentationSink` Place projection for the local
player** — route 2's B2 recorded that no test drives a local-player
Place end-to-end through the sink to a moved `WorldEntity`; route 3 makes
that seam load-bearing for portals and MUST close the coverage gap (an
App-layer test asserting the render entity moved from the committed
receipt), not inherit it.
---
## Summary for the campaign plan
- **Size: ~225-400 added non-comment production lines, net ~+150-250 — ONE
slice, no split** (contingent on #280 splitting out).
- **#280: SPLIT.** The handoff's "rides with route 3" is wrong; the campaign
plan's own sequencing (separate item) is right. Reveal-window/streaming
concern, not placement.
- **Eight dated-inventory claims now false** (§3), the most consequential
being "100% dormant end to end" — the validation half of the portal
machinery is live production code; only the producer is missing.
- **Contradictions with the campaign plan:** (a) plan line ~92-93's "the
adapter … does not exist" overstates the gap — only the producer is
missing; (b) the plan's #280 sequencing contradicts the session handoff's
bundling — this scoping sides with the plan; (c) the inventory's "route
3's target is `RuntimeSetPositionState` directly" is refined: the target
is a portal arm on route 2's `RuntimeAcceptedPositionDriveController` seam
OVER `RuntimeSetPositionState`.
- **Two documentation defects found while verifying:** the 2026-07-16 portal
pseudocode attributes portal arrival to `enter_world` (that is the LOGIN
path — needs a correction note), and
`LiveEntityNetworkUpdateController.cs:2276`'s "the local player never
reaches this generic-remote code path" is false for
`TryApplyGenericRemoteRenderPose`/`RebucketLiveEntity` (T8).

View file

@ -0,0 +1,520 @@
# Retail: does a parent's cell crossing propagate to its children?
**Date:** 2026-08-04
**Worktree:** `peaceful-visvesvaraya-e0a196`, HEAD `cff52c44`
**Mode:** read-only retail research. No production or test code written. Nothing committed.
**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build,
PDB-named) + `docs/research/named-retail/acclient.h` (verbatim retail struct definitions).
**Consumer:** C4 route 7 contract — demoting `EquippedChildRenderController.TickChild`
to presentation-only and moving the authoritative child-cell write into Runtime's
`TryCommitParent` / `CommitAcceptedParentCellless`.
---
## VERDICT
**YES. Retail propagates a parent's cell to its children, recursively, to unbounded
depth, on every parent cell crossing — and additionally refreshes each direct child's
`objcell_id` on every physics tick in which the parent moves within its current cell.**
A `set_parent`-only cell write is **NOT** retail-faithful. It is correct at attach and
stale from the parent's first cell crossing onward. Route 7's contract as originally
scoped would reintroduce the #184 invisible-but-solid class exactly as feared.
The good news for route 7: the propagation is driven by the **physics position commit**
(`CPhysicsObj::SetPositionInternal` @`0x00515330`), *not* by anything render-side. So
moving the authoritative write into Runtime is the right direction — the contract just
has to be *"parent cell change propagates to children"*, not *"set_parent writes once"*.
**No cdb trace is required.** The static read is unambiguous, and the struct-offset
arithmetic independently corroborates the one place Binary Ninja lost field names.
See §7 for why, and §8 for the (unnecessary) breakpoint set if the user wants
belt-and-braces confirmation anyway.
---
## 1. The gap named by the scoping doc, now closed
`docs/research/2026-08-04-c4-routes-6-7-scoping.md` §7.4 / §7.8 T5 stated that
`CPhysicsObj::change_cell` and `CPhysicsObj::set_cell`'s own child handling
"were not read". Reading them is the whole answer.
Two corrections to the framing up front:
1. There is **no `CPhysicsObj::set_cell`** in the 2013 build. The functions that exist
are `CPhysicsObj::set_cell_id` @`0x0050f4f0`,
`CPhysicsObj::set_cell_id_recursive` @`0x00510da0`, and
`CPhysicsObj::change_cell` @`0x00513390`. I searched the full 1,437,645-line
pseudo-C; `set_cell` as a symbol does not appear.
2. `change_cell` itself contains **no child loop**. It delegates entirely — and the
delegates (`leave_cell`, `enter_cell`) are where the recursion lives. That is why
a reader skimming `change_cell` alone would conclude "no propagation", which is
the trap this doc exists to close.
---
## 2. `change_cell` @`0x00513390` — the dispatcher
Read verbatim:
```
00513390 void __thiscall CPhysicsObj::change_cell(class CPhysicsObj* this, class CObjCell* arg2)
0051339b if (this->cell != 0)
0051339f CPhysicsObj::leave_cell(this, 1);
005133aa if (arg2 != 0)
005133af CPhysicsObj::enter_cell(this, arg2);
005133b5 return;
005133c1 this->m_position.objcell_id = 0;
005133c8 if ((state & 0x1000) == 0)
005133d3 CPartArray::SetCellID(part_array, 0);
005133d8 this->cell = nullptr;
```
- @`0x0051339f` — unconditional (given a non-null current cell) `leave_cell`.
- @`0x005133af` — unconditional (given a non-null target) `enter_cell`, then **early
return** @`0x005133b5`. The tail from @`0x005133c1` is the *removal-only* path
(`arg2 == 0`).
Both delegates recurse into `children`. That is the propagation.
**Asymmetry worth recording:** on the `arg2 == 0` (removal) path, only `this`'s
`objcell_id` is zeroed @`0x005133c1`. `leave_cell` nulls each *child's* `cell` pointer
but never touches a child's `objcell_id` (§4). So after a removal, children are left
with `cell == nullptr` and a **stale non-zero `objcell_id`**. This is retail behavior,
observed not inferred; it matters if acdream ever treats `objcell_id != 0` as a
liveness predicate for children.
---
## 3. `enter_cell` @`0x00510ed0` — the recursion, and what it writes
Read verbatim:
```
00510ed0 void __thiscall CPhysicsObj::enter_cell(class CPhysicsObj* this, class CObjCell* arg2)
00510ed8 if (this->part_array != 0)
00510ee2 CObjCell::add_object(arg2, this);
00510ee7 class CHILDLIST* children = this->children;
00510eec if (children != 0)
00510ef4 if (children->num_objects > 0)
00510f0f do
00510f03 CPhysicsObj::enter_cell(this->children->objects.data[edi_1], arg2);
00510f0b edi_1 += 1;
00510f0f while (edi_1 < this->children->num_objects);
00510f1b uint32_t id = arg2->m_DID.id;
00510f1e this->m_position.objcell_id = id;
00510f21 if ((state & 0x1000) == 0)
00510f2b CPartArray::SetCellID(part_array, id);
00510f35 this->cell = arg2;
00510f3e CPartArray::AddLightsToCell(part_array_1, arg2);
```
Answering the task's question 3 directly — **what does it recurse over?**
`this->children->objects.data[i]` for `i` in `[0, children->num_objects)`
@`0x00510f03`. It is **self-recursive**, so the recursion is **unbounded depth**, not
depth-1: a child's own children are reached too.
**What each recursion level writes** (i.e. what every child in the subtree gets):
| Address | Write | Effect on the child |
|---|---|---|
| `0x00510ee2` | `CObjCell::add_object(arg2, child)` | child joins the new cell's object list |
| `0x00510f1e` | `child->m_position.objcell_id = arg2->m_DID.id` | **canonical cell id** |
| `0x00510f2b` | `CPartArray::SetCellID(child->part_array, id)` | render/part-array cell id |
| `0x00510f35` | `child->cell = arg2` | **canonical cell pointer** |
| `0x00510f3e` | `CPartArray::AddLightsToCell(child->part_array, arg2)` | lights re-registered |
So a child receives the **complete** cell identity — pointer, id, cell-list membership,
and lights — identical to what the parent receives. Every child ends up in the *same*
`CObjCell` as the parent (`arg2` is passed down unchanged @`0x00510f03`).
**Guard, load-bearing:** @`0x00510ed8` the entire body is gated on
`this->part_array != 0`. A child with a null part array receives **nothing** — no cell,
no `objcell_id`, no membership. Recursion also stops there, so that child's own
subtree is skipped.
---
## 4. `leave_cell` @`0x00510f50` — the matching recursive teardown
```
00510f50 void __thiscall CPhysicsObj::leave_cell(class CPhysicsObj* this, int32_t arg2)
00510f53 class CObjCell* cell = this->cell;
00510f5b if (cell != 0)
00510f5e CObjCell::remove_object(cell, this);
00510f63 class CHILDLIST* children = this->children;
00510f68 if (children != 0)
00510f70 if (children->num_objects > 0)
00510f90 do
00510f84 CPhysicsObj::leave_cell(this->children->objects.data[edi_1], arg2);
00510f8c edi_1 += 1;
00510f90 while (edi_1 < this->children->num_objects);
00510f94 class CPartArray* part_array = this->part_array;
00510fa2 CPartArray::RemoveLightsFromCell(part_array, this->cell);
00510fa7 this->cell = nullptr;
```
Also self-recursive @`0x00510f84`, also unbounded depth. Per child:
`CObjCell::remove_object` @`0x00510f5e`, `RemoveLightsFromCell` @`0x00510fa2`,
`cell = nullptr` @`0x00510fa7`.
**Note what is absent:** `leave_cell` never writes `objcell_id`. That is the source of
the §2 asymmetry. `arg2` (the `1` passed from `change_cell` @`0x0051339f`) is threaded
through the recursion @`0x00510f84` but is **never read** in the body — dead in this
build.
**Guard:** @`0x00510f5b` gated on `this->cell != 0`, evaluated per recursion level. A
child already cell-less is skipped along with its subtree.
---
## 5. The depth-1 child loop in `SetPositionInternal` @`0x0051539c`@`0x005153d8`
Answering the task's question 2. First, the containing function's identity:
@`0x00515330` is
`int32_t __thiscall CPhysicsObj::SetPositionInternal(class CPhysicsObj* this, class CTransition const* arg2)`
— the **two-argument overload**, i.e. the post-transition position commit. (Distinct
from the four-arg `SetPositionInternal` @`0x00515bd0`, which calls into it
@`0x00515c94`.)
The relevant branch:
```
0051534a class CObjCell* curr_cell = arg2->sphere_path.curr_cell;
00515360 if (curr_cell == 0) // → lost-cell path
0051536d if (this->cell == curr_cell) // SAME-CELL branch
00515385 this->m_position.objcell_id = objcell_id;
00515392 CPartArray::SetCellID(part_array, objcell_id);
0051539c if (children != 0)
005153a3 if (children->num_objects > 0)
005153d8 do
005153ae void* eax_2 = this->children->objects.data[ebx_1];
005153b7 cond:4_1 = (*(child + 0xa8) & 0x1000) != 0;
005153ba objcell_id_1 = arg2->sphere_path.curr_pos.objcell_id;
005153bd *(uint32_t*)((char*)eax_2 + 0x4c) = objcell_id_1;
005153c0 if (!cond:4_1)
005153cc CPartArray::SetCellID(*(char*)eax_2 + 0x10, objcell_id_1);
005153d4 ebx_1 += 1;
005153d8 while (ebx_1 < this->children->num_objects);
0051536d else
00515372 CPhysicsObj::change_cell(this, curr_cell); // CELL-CHANGE branch
005153e0 CPhysicsObj::set_frame(this, &arg2->sphere_path.curr_pos.frame);
```
**Binary Ninja lost the field names here** (it typed the loop variable as `void*`), so
the writes appear as raw offsets. Resolving them from the verbatim header
`docs/research/named-retail/acclient.h` — this is **arithmetic, not inference**:
`struct CPhysicsObj : LongHashData` member walk, anchored on the fact that BN itself
names offset `0x10` as `part_array` in the sibling functions
(`set_cell_id_recursive` @`0x00510da0` etc.):
| Offset | Member |
|---|---|
| `0x10` | `CPartArray *part_array` |
| `0x14``0x1C` | `AC1Legacy::Vector3 player_vector` |
| `0x20` | `float player_distance` |
| `0x24` | `float CYpt` |
| `0x28` | `CSoundTable *sound_table` |
| `0x2C` | `bool m_bExaminationObject` (align 4) |
| `0x30` | `ScriptManager *script_manager` |
| `0x34` | `PhysicsScriptTable *physics_script_table` |
| `0x38` | `PScriptType default_script` |
| `0x3C` | `float default_script_intensity` |
| `0x40` | `CPhysicsObj *parent` |
| `0x44` | `CHILDLIST *children` |
| `0x48` | `Position m_position``PackObj` vtable ptr |
| **`0x4C`** | **`m_position.objcell_id`** |
| `0x50``0x8C` | `m_position.frame` (`qw..qz`, `m_fl2gv[9]`, `m_fOrigin`) |
| `0x90` | `CObjCell *cell` |
| `0x94` | `unsigned int num_shadow_objects` |
| `0x98``0xA4` | `DArray<CShadowObj> shadow_objects` (4 dwords) |
| **`0xA8`** | **`unsigned int state`** |
The walk lands exactly on `0x4C = m_position.objcell_id`, `0x10 = part_array`, and
`0xA8 = state` — all three offsets used by the loop, all three consistent. There is no
residual ambiguity and **no PE byte-decode is needed** for this site.
**So what does the depth-1 loop write?** Per direct child:
- `child->m_position.objcell_id = curr_pos.objcell_id` @`0x005153bd`
- `CPartArray::SetCellID(child->part_array, objcell_id)` @`0x005153cc`, gated on the
**child's own** `state & 0x1000` @`0x005153b7`
**Cell id only — NOT the `cell` pointer** (`0x90` is never written here), and **NOT
recursive** (children-of-children are not visited in this branch).
That is coherent, not a bug: this branch is entered precisely when
`this->cell == curr_cell` @`0x0051536d`, i.e. the parent did **not** change cell — so
every child's `cell` pointer is already correct and needs no write. The loop is a
cheap per-tick id refresh, not a re-cell.
**Therefore the depth-1 loop is *not* the answer to the route-7 question.** It is the
same-cell fast path. The answer is the `else` @`0x00515372`.
---
## 6. Cadence — when each path actually runs
`CPhysicsObj::UpdateObjectInternal` @`0x005156b0` is the per-tick physics update. It
runs the transition and commits:
```
005158b2 class CTransition* eax_10 = CPhysicsObj::transition(this, &this->m_position, &var_48, 0);
005158bb if (eax_10 == 0)
00515937 CPhysicsObj::set_frame(this, &var_40); // blocked → frame only
005158bb else
00515914 CPhysicsObj::SetPositionInternal(this, eax_10); // moved → commit
```
So `SetPositionInternal` @`0x00515330` runs **every physics tick in which the object
successfully moves** (@`0x00515914`). Inside it the branch @`0x0051536d` selects:
| Parent's tick | Branch | Children get |
|---|---|---|
| Moved, same cell | @`0x0051536d` same-cell | depth-1 `objcell_id` + part-array id refresh (@`0x005153bd`, @`0x005153cc`) |
| **Moved, crossed a cell** | @`0x00515372``change_cell` | **full recursive re-cell: `remove_object` / `add_object`, `objcell_id`, `cell` pointer, part-array id, lights** |
| Blocked (`transition` returned 0) | @`0x00515937` `set_frame` | frame only — no cell work needed, parent didn't move |
| `curr_cell == 0` | @`0x00515360` lost-cell | `GotoLostCell` @`0x00515579`; no child cell work |
Other entry points that reach the same recursive propagation:
- `CPhysicsObj::ForceIntoCell` @`0x00515660``change_cell` @`0x00515684`, guarded by
`this->cell != arg2` @`0x0051567f`. (Teleport / corpse forcing — reached from
@`0x00515c61`.)
- `CPhysicsObj::AddObjectToSingleCell` @`0x005149e0``change_cell` @`0x005149ff`.
- `CPhysicsObj::add_obj_to_cell` @`0x005159e0``enter_cell` @`0x005159e9` directly
(then `UpdateChildrenInternal` @`0x00515a17`, `calc_cross_cells_static` @`0x00515a1e`).
- `CPhysicsObj::set_parent` @`0x00515a90``change_cell` @`0x00515ad6` (the
already-established attach-time write), and the 4-arg overload @`0x00515b50`
`change_cell` @`0x00515b9a`.
- 4-arg `SetPositionInternal` @`0x00515bd0` → 2-arg @`0x00515c94`.
### 6.1 The clincher: a child never self-updates
`CPhysicsObj::update_object` @`0x00515d10` opens with:
```
00515d40 if ((this->parent != 0 || (this->cell == 0 || (this->state & 0x1000000) != 0)))
00515eeb this->transient_state &= 0xffffff7f;
00515ef5 return;
```
**`parent != 0` → immediate return.** A parented object is excluded from its own physics
tick entirely. It never calls `transition`, never calls `SetPositionInternal`, never
touches its own cell.
This is the structural proof that closes the question: since a child *cannot* update its
own cell, and children demonstrably do end up in the right cell in retail, **parent
propagation is the only mechanism that exists.** If `enter_cell`'s recursion did not
write the child's cell, an equipped weapon would be permanently stranded in the cell
where it was equipped — which is precisely the #184 symptom, and is not what retail
does.
---
## 7. Read vs. inferred — explicit ledger
Per the standards in the task, separating what the source says from what I concluded.
**Read directly from the pseudo-C (verbatim, cited):**
- `change_cell` delegates to `leave_cell` / `enter_cell` (@`0x0051339f`, @`0x005133af`).
- `enter_cell` recurses over `children->objects.data[i]` (@`0x00510f03`) and writes
`objcell_id` (@`0x00510f1e`), part-array cell id (@`0x00510f2b`), `cell` pointer
(@`0x00510f35`), and `CObjCell::add_object` (@`0x00510ee2`).
- `leave_cell` recurses (@`0x00510f84`) and writes `cell = nullptr` (@`0x00510fa7`) +
`remove_object` (@`0x00510f5e`).
- Both recursions are self-calls → unbounded depth.
- `SetPositionInternal` @`0x00515330` branches on `this->cell == curr_cell`
(@`0x0051536d`); `else``change_cell` (@`0x00515372`).
- The depth-1 loop (@`0x0051539c`@`0x005153d8`) writes child `+0x4c` and child
`+0x10`'s cell id only.
- `UpdateObjectInternal` calls `SetPositionInternal` per moving tick (@`0x00515914`).
- `update_object` early-returns on `parent != 0` (@`0x00515d40`).
- `references/` in this worktree contains **only `WorldBuilder`**. ACE is **not
present** — see §9.
**Resolved by arithmetic, not guessed:** child `+0x4c` = `m_position.objcell_id`,
`+0x10` = `part_array`, `+0xa8` = `state`. Derived by walking `struct CPhysicsObj` in
`acclient.h` (§5 table), independently anchored on BN's own naming of `0x10` as
`part_array` in sibling functions. I regard this as read, not inferred.
**Inferred (flagged as such):**
- That the §2 removal-path asymmetry (child keeps a stale `objcell_id` while `cell` goes
null) is *intentional* rather than a latent retail bug. The code plainly does it;
the intent is my reading. It does not affect the verdict.
- That `state & 0x1000` is a "suppress part-array sync" flag. The bit is checked
identically at @`0x005153b7`, @`0x00510f21`, @`0x005133c8`, @`0x005140f7`,
@`0x00510db4`; I did not chase its symbolic name because the verdict does not
depend on it.
- That `leave_cell`'s `arg2` being unread is dead-parameter residue rather than
something BN elided. It is absent from the decompiled body; a byte-decode could
confirm, but nothing hinges on it.
**Binary Ninja elisions encountered:** exactly one site of consequence — the `void*`
typing in the §5 loop, fully resolved by the header walk. Elsewhere the x87 comparison
idioms are mangled (e.g. @`0x00515473`) but sit in the contact-plane / walkable code,
not on the child-cell path. **No PE byte-decode against
`C:\Users\erikn\Downloads\acclient.exe` + `refs/acclient.pdb` is required for this
question.**
---
## 8. Secondary observation: cross-cells / shadow lists are *not* refreshed per move
Worth recording because it is an adjacent trap, and because it distinguishes two things
route 7 might otherwise conflate.
`CPhysicsObj::recalc_cross_cells` @`0x00515a30` **does** recurse over children
@`0x00515a79`. But on the movement path, `SetPositionInternal`'s tail calls only the
**non-recursive** forms:
```
0051550b if (this->cell != 0)
00515517 if ((this->state & 0x10000) != 0)
0051551b CPhysicsObj::calc_cross_cells(this); // this only
0051552b return 1;
0051553a if (arg2->cell_array.num_cells > 0)
0051553e CPhysicsObj::remove_shadows_from_cells(this);
0051554c CPhysicsObj::add_shadows_to_cells(this, &arg2->cell_array);
```
`recalc_cross_cells` is reached only at attach (`set_parent` @`0x00515b15`,
@`0x00515bab`) and via `calc_cross_cells_static` @`0x00515a1e` in `add_obj_to_cell`.
**Meaning:** a child's *canonical* cell (pointer + id + `CObjCell` membership) **is**
maintained across parent movement; its *cross-cell / shadow* registration is **not**
re-derived per parent tick. If acdream's child handling has a shadow-cell analogue,
matching retail means propagating the canonical cell but **not** rebuilding child
shadow lists every tick.
There is also a third, movement-unrelated recursive helper:
`CPhysicsObj::set_cell_id_recursive` @`0x00510da0` — recurses @`0x00510de3`, writes
`objcell_id` @`0x00510db1` + part-array id @`0x00510dbe`, but **not** the `cell`
pointer. Callers are the sky-object path @`0x00506eba` and `CObjectMaint::GotoLostCell`
@`0x00508210` (via `set_cell_id` @`0x00508226`). Not on the equipped-child path;
listed so it isn't mistaken for the propagation mechanism.
---
## 9. ACE cross-check — NOT PERFORMED, reference absent
The task asked for an ACE cross-check. **`references/` in this worktree contains only
`WorldBuilder`.** ACE is not vendored here (`find` over the worktree returns only
`docs/reference/ace-commands.md`, a command catalog, not source).
Per the task's own instruction, I am saying so rather than guessing. I have made **no
claim** about what `ACE.Server/Physics/` does with `change_cell` / `enter_cell` child
handling. If a cross-check is wanted, it needs a worktree with ACE present, or a run
from the main repo; the ACE files to read would be
`Source/ACE.Server/Physics/PhysicsObj.cs` (`change_cell`, `enter_cell`, `leave_cell`,
`set_parent`, `SetPositionInternal`) and `Source/ACE.Server/Physics/Common/ObjCell.cs`.
Note that ACE is in any case only an interpretation aid here — per CLAUDE.md's
workflow, the decompiled retail code is ground truth and wins any disagreement. The
retail read above is unambiguous, so an ACE disagreement would not change the verdict;
it would only be interesting as a note about ACE.
---
## 10. Consequences for route 7's contract
**The contract can be pinned now, without a cdb trace.** What it must say:
1. **`set_parent`-time write is necessary but not sufficient.** Retail writes the
child's cell at attach (`set_parent` @`0x00515ad6`) **and** at every subsequent
parent cell crossing (`SetPositionInternal` @`0x00515372``change_cell`). Route 7
must implement both.
2. **The authoritative write belongs on the parent's physics-commit path, not a render
tick.** Retail's trigger is `SetPositionInternal` @`0x00515330`, reached from
`UpdateObjectInternal` @`0x00515914`. So demoting
`EquippedChildRenderController.TickChild` to presentation-only is **directionally
correct** — the render tick was never retail's owner of this write. But the
authoritative write must land in `TryCommitParent` /
`CommitAcceptedParentCellless` **as a cell-change propagation step**, not as a
one-shot at attach.
3. **Propagation is recursive to unbounded depth**, not depth-1
(`enter_cell` @`0x00510f03`, `leave_cell` @`0x00510f84`). If acdream can ever nest
children (child-of-child), the propagation must recurse. If acdream's model is
structurally depth-1 for equipped items, a depth-1 implementation is
behaviorally equivalent — but that equivalence should be stated as an explicit
assumption in the contract, with a register row if it is load-bearing.
4. **The child's write is the full cell identity**, not just the id: `cell` pointer
(@`0x00510f35`), `objcell_id` (@`0x00510f1e`), cell-list membership
(@`0x00510ee2` / @`0x00510f5e`), part-array cell id (@`0x00510f2b`). A partial write
(id without membership) would leave the #184 class only half-closed.
5. **Same-cell ticks still refresh child `objcell_id`** (@`0x005153bd`) but must **not**
rewrite the child's `cell` pointer. Retail deliberately splits these.
6. **Do not rebuild child cross-cell/shadow lists per parent tick** (§8) — retail
doesn't, and doing so would be a performance divergence with no faithfulness gain.
7. **Children are excluded from their own physics tick** (`update_object` @`0x00515d10`,
guard @`0x00515d40`). If acdream ever ticks an equipped child through the ordinary
physics path, that is itself a divergence independent of this question.
**Register note:** if route 7 ships a depth-1-only propagation (item 3) or omits the
same-cell `objcell_id` refresh (item 5), each is a deviation and needs its row in
`docs/architecture/retail-divergence-register.md` in the same commit, per the workflow
rules.
---
## 11. cdb breakpoints — NOT needed, recorded only for completeness
The task asked for the breakpoint set *only if reading truly cannot answer it*. Reading
answers it. I am recording the set anyway so nobody has to re-derive it if the user
wants independent confirmation before pinning a contract this load-bearing.
Per `memory/reference_retail_debugger.md`: `qd` inside a `bp` action is forbidden;
use counters + `gc`, and mind the hit-rate lag.
```
.logopen C:\Users\erikn\parent-cell-prop.log
.sympath C:\Users\erikn\source\repos\acdream\refs
.symopt+ 0x40
.reload /f acclient.exe
r $t0 = 0
r $t1 = 0
* parent crossed a cell: change_cell entry, dump this + target cell
bp acclient!CPhysicsObj::change_cell "r $t0 = @$t0 + 1; .printf \"CC obj=%p cell=%p newcell=%p children=%p\\n\", @ecx, poi(@ecx+0x90), poi(@esp+4), poi(@ecx+0x44); gc"
* per-child re-cell: enter_cell, dump the object and the cell it is being put in
bp acclient!CPhysicsObj::enter_cell "r $t1 = @$t1 + 1; .printf \"EC obj=%p parent=%p oldcell=%p newcell=%p oldid=%x\\n\", @ecx, poi(@ecx+0x40), poi(@ecx+0x90), poi(@esp+4), poi(@ecx+0x4c); gc"
g
```
**Predicted trace if the verdict is right:** equip a weapon, then walk across a
landblock boundary. Each crossing produces one `CC` line for the player object followed
immediately by **N+1** `EC` lines — one for the player and one per equipped child — all
sharing the same `newcell`, and each child's `oldcell` equal to the player's pre-cross
cell. The child `EC` lines are the propagation; their absence would falsify the verdict.
A lighter confirmation, if breakpoint lag on `enter_cell` is a problem (it is called
often): breakpoint only `change_cell` and, at each hit, walk
`CHILDLIST* children = poi(@ecx+0x44)``objects.data` and dump each child's `+0x90`
(`cell`) before and after with a second breakpoint on the return. More setup, far fewer
traps.
---
## Bottom line
Retail re-cells children when the parent crosses a cell boundary. The mechanism is
`SetPositionInternal` @`0x00515372``change_cell` @`0x00513390`
`leave_cell` @`0x00510f50` (recursive @`0x00510f84`) + `enter_cell` @`0x00510ed0`
(recursive @`0x00510f03`), and the child's full cell identity is written at
@`0x00510ee2` / @`0x00510f1e` / @`0x00510f35`. Children never self-update
(`update_object` guard @`0x00515d40`), so this is the only mechanism.
Route 7 must propagate. A `set_parent`-only write would strand equipped items at
landblock boundaries.