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