From ca96ea5e32084feaef32e098bcf62a328958e95b Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 4 Aug 2026 21:23:56 +0200 Subject: [PATCH] research: settle retail parent-cell propagation and scope C4 route 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../research/2026-08-04-c4-route-3-scoping.md | 600 ++++++++++++++++++ ...26-08-04-retail-parent-cell-propagation.md | 520 +++++++++++++++ 2 files changed, 1120 insertions(+) create mode 100644 docs/research/2026-08-04-c4-route-3-scoping.md create mode 100644 docs/research/2026-08-04-retail-parent-cell-propagation.md diff --git a/docs/research/2026-08-04-c4-route-3-scoping.md b/docs/research/2026-08-04-c4-route-3-scoping.md new file mode 100644 index 00000000..8920c932 --- /dev/null +++ b/docs/research/2026-08-04-c4-route-3-scoping.md @@ -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). diff --git a/docs/research/2026-08-04-retail-parent-cell-propagation.md b/docs/research/2026-08-04-retail-parent-cell-propagation.md new file mode 100644 index 00000000..ebd9c9bf --- /dev/null +++ b/docs/research/2026-08-04-retail-parent-cell-propagation.md @@ -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 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.