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,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.