# Retail child cell ownership — does a parent's cell change propagate to its children? Date: 2026-08-04 Scope: Campaign C4 route 7 — settling whether the parented-child cell write belongs in Runtime's `set_parent` analog. Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build, PDB-named) + `docs/research/named-retail/acclient.h` (verbatim retail structs). Ghidra MCP was probed on 8080 and 8081 and was **not** live; the text decomp is authoritative and was sufficient. **Verdict up front: YES, retail propagates a parent's cell to its children, and it does so from the physics update path — twice over, on two different branches. Route 7's premise is sound; App's render-tick rebucket is a divergence, not the mechanism.** --- ## 0. Struct offsets used below (verified, not assumed) Binary Ninja renders several of the child writes as raw offset arithmetic (`*(uint32_t*)((char*)eax_2 + 0x4c)`), so the offsets were resolved from `acclient.h` rather than trusted from a heuristic field name. `CPhysicsObj : LongHashData` (`acclient.h:30689`), `LongHashData : HashBaseData` (`acclient.h:30149`), `HashBaseData = { vfptr, hash_next, id }` = 12 bytes (`acclient.h:30135`). | Offset | Field | Derivation | |---|---|---| | `0x0c` | `netblob_list` | base 12 | | `0x10` | `part_array` | confirmed by use as `CPartArray*` at `0x005153c2` | | `0x40` | `parent` | field order | | `0x44` | `children` | field order | | `0x48` | `m_position` | `Position : PackObj`, `PackObj` = `{ vfptr }` = 4 bytes (`acclient.h:26018`) | | **`0x4c`** | **`m_position.objcell_id`** | `Position` = vfptr(4) + objcell_id(4) + `Frame`(64) = 72 (`acclient.h:30658`) | | `0x90` | `cell` | `0x48 + 72` | | `0xa8` | `state` | `cell`(4) + `num_shadow_objects`(4) + `DArray`(16); confirmed by the identical `[1] & 0x10` bit-test used against `this->state` in `enter_cell` | `Frame` = `qw,qx,qy,qz`(16) + `m_fl2gv[9]`(36) + `m_fOrigin`(12) = 64 (`acclient.h:30647`). The chain closes exactly on `state == 0xa8`, so `+0x4c` is `m_position.objcell_id` with no slack. There is **no `CPhysicsObj::set_cell`**. `symbols.json` contains only `CPhysicsObj::set_cell_id` and `CPhysicsObj::set_cell_id_recursive`. The cell-transition entry point is `change_cell`. --- ## 1. Does a parent's cell change propagate to children? **Yes. Two independent mechanisms, both on the physics path.** ### Mechanism A — the recursive cell walk (`change_cell` → `enter_cell`) `CPhysicsObj::change_cell @0x00513390`: ``` 0051339b if (this->cell != 0) leave_cell(this, 1) @0x0051339f 005133aa if (arg2 != 0) { enter_cell(this, arg2); return; } @0x005133af 005133c1 this->m_position.objcell_id = 0 // arg2 == 0 path only 005133d8 this->cell = nullptr ``` `change_cell` itself has no child loop. Both of its callees do. `CPhysicsObj::enter_cell @0x00510ed0` — **recurses into children with the same `CObjCell*`**: ``` 00510ed8 if (this->part_array != 0) { 00510ee2 CObjCell::add_object(arg2, this) 00510eec for (edi_1 = 0; edi_1 < children->num_objects; edi_1++) 00510f03 CPhysicsObj::enter_cell(this->children->objects.data[edi_1], arg2) // RECURSION 00510f1b id = arg2->m_DID.id 00510f1e this->m_position.objcell_id = id // WRITE 00510f2b CPartArray::SetCellID(part_array, id) 00510f35 this->cell = arg2 // WRITE 00510f3e CPartArray::AddLightsToCell(part_array, arg2) } ``` Because `arg2` is passed down unchanged, every descendant receives the parent's **exact** cell object and therefore the identical `objcell_id`. Each descendant is also individually registered into the cell via `CObjCell::add_object` `@0x00510ee2` on its own recursion frame — children are real members of the cell's object list, not merely nominally re-tagged. `CPhysicsObj::leave_cell @0x00510f50` mirrors it: ``` 00510f5e CObjCell::remove_object(cell, this) 00510f84 for each child: CPhysicsObj::leave_cell(child, arg2) // RECURSION 00510fa7 this->cell = nullptr ``` Note `leave_cell` clears `cell` but does **not** zero `objcell_id`, on the parent or on children. The stale id survives until `enter_cell` overwrites it. **Caveat, cited:** `enter_cell` is gated on `this->part_array != 0` `@0x00510ed8`. A child with a null `part_array` receives no cell write and its own children are not visited. `leave_cell` has no such gate. ### Mechanism B — the explicit child loop in the motion commit `CPhysicsObj::SetPositionInternal(CPhysicsObj*, CTransition const*) @0x00515330` is the per-commit position write. It branches on whether the `CObjCell` pointer changed: ``` 0051534a curr_cell = arg2->sphere_path.curr_cell 0051536d if (this->cell == curr_cell) { // BRANCH A: same cell object 00515385 this->m_position.objcell_id = arg2->sphere_path.curr_pos.objcell_id 00515392 CPartArray::SetCellID(part_array, objcell_id) 0051539c for (ebx_1 = 0; ebx_1 < this->children->num_objects; ebx_1++) { 005153ae eax_2 = this->children->objects.data[ebx_1] 005153ba objcell_id_1 = arg2->sphere_path.curr_pos.objcell_id 005153bd *(uint32_t*)((char*)eax_2 + 0x4c) = objcell_id_1 // child->m_position.objcell_id 005153cc CPartArray::SetCellID(eax_3 /* child +0x10 */, objcell_id_1) } } else 00515372 CPhysicsObj::change_cell(this, curr_cell) // BRANCH B: → Mechanism A 005153e0 CPhysicsObj::set_frame(this, &arg2->sphere_path.curr_pos.frame) ``` So the child's `objcell_id` is re-stamped with the parent's on **every position commit**, whether or not the `CObjCell` pointer changed. Branch A writes it unconditionally — it does not test whether the value differs. **Two precise asymmetries between the branches, both citable:** - Branch A's loop is **flat, depth-1 only** (`@0x005153ae`–`@0x005153d8` reads `this->children` and writes `+0x4c` directly, with no recursive call). Mechanism A's `enter_cell` is fully recursive. Grandchildren are therefore refreshed only on a genuine cell-object change. - Branch A writes only `objcell_id` (and the part array's copy), never the child's `cell` pointer. It does not need to: the parent's `CObjCell` is unchanged in that branch, and the children were already registered into it by `enter_cell`. ### The driving tick `CPhysics::UseTime @0x00509950` → `CPhysicsObj::update_object @0x005099e5` (`@0x00515d10`) → `CPhysicsObj::UpdateObjectInternal @0x005156b0` → `SetPositionInternal(this, transition) @0x00515914`. The decisive guard is the first thing `update_object` does: ``` 00515d40 if (this->parent != 0 || this->cell == 0 || (this->state & 0x1000000) != 0) { 00515eeb this->transient_state &= 0xffffff7f 00515ef5 return } ``` **A parented object is never independently simulated.** Its cell — and its frame — come exclusively from its parent's tick. This is the structural reason the propagation in Mechanism A/B must exist and must be unconditional. --- ## 2. What is a child's `objcell_id` while parented? **The parent's, maintained eagerly and redundantly. Not zero, not independent.** Write sites that set a child's `objcell_id` to the parent's value: | Site | Address | Value written | |---|---|---| | `enter_cell` recursion frame | `@0x00510f1e` | `arg2->m_DID.id` — the parent's `CObjCell` id | | `SetPositionInternal` branch A loop | `@0x005153bd` | `arg2->sphere_path.curr_pos.objcell_id` — the parent's committed id | | `set_cell_id_recursive` | `@0x00510db1` | `arg2`, recursed to children `@0x00510de3` | The child's copy is a genuine maintained mirror, not a derived read-through: `CPhysicsObj` has exactly one `m_position` and the child's is written with the parent's id at the sites above. **`set_cell_id_recursive @0x00510da0` is not part of the parenting path.** A whole-file grep for callers returns exactly one, `@0x00506eba`, which is sky object handling (`this->sky_obj.m_data[i]`). It is cited here only to close it out as a candidate — it is not the mechanism. --- ## 3. Where is a child's cell written? **In `change_cell`/`enter_cell` and in the `SetPositionInternal` commit — i.e. the physics path. `set_parent` participates only by calling `change_cell`; it contains no cell write of its own.** `CPhysicsObj::set_parent(CPhysicsObj*, uint32_t) @0x00515a90`, complete body: ``` 00515a9d if (edi != 0 && add_child(edi, this, arg3) != 0) { 00515aba unset_parent(this) 00515ac1 leave_world(this) 00515ac6 this->parent = edi 00515ac9 cell = edi->cell 00515ad1 if (cell != 0) { 00515ad6 change_cell(this, cell) // <-- the only cell write, delegated 00515ae0 if (children_1 != 0 && CHILDLIST::FindChildIndex(children_1, this, &arg2) != 0) { 00515b0e UpdateChild(edi, this, children->part_numbers.data[ecx_4], &children->frames.data[ecx_4]) 00515b15 recalc_cross_cells(this) } } 00515b26 if (this->parent->state[1] & 0x40) { 00515b28 this->state |= 0x20 00515b38 CPartArray::SetNoDrawInternal(part_array, 1) } 00515b45 return 1 } 00515b4d return 0 ``` The four-arg overload `set_parent(CPhysicsObj*, uint32_t, Frame const*) @0x00515b50` is identical in shape: `unset_parent @0x00515b7e`, `leave_world @0x00515b85`, `this->parent = arg2 @0x00515b8a`, `change_cell(this, cell) @0x00515b9a`, `UpdateChild @0x00515ba4`, `recalc_cross_cells @0x00515bab`, plus `m_bExaminationObject` propagation `@0x00515b7b`. Supporting facts: - `CPhysicsObj::add_child @0x0050f870` (and the 4-arg form `@0x0050f8f0`) is pure list management — `CSetup::GetHoldingLocation @0x0050f896`, allocate `CHILDLIST` `@0x0050f8aa`, `CHILDLIST::add_child @0x0050f8d1`. **No cell or `objcell_id` write.** - `CPhysicsObj::recalc_cross_cells @0x00515a30` recurses into children `@0x00515a79` but only calls `calc_cross_cells @0x00515a5c` (shadow/cross-cell registration) — it **reads** `this->m_position.objcell_id` as a guard `@0x00515a3f` and never writes it. - **Prior finding CONFIRMED, not refuted:** `CPhysicsObj::UpdateChild @0x00512d50` composes a frame (`Frame::combine @0x00512d7d`) and calls `set_frame(arg2, &var_40) @0x00512d8d`, then ticks particles/scripts. `CPhysicsObj::set_frame @0x00514090` writes `m_position.frame` `@0x005140e9`, `CPartArray::SetFrame @0x00514101`, and `UpdateChildrenInternal @0x00514108`. **Neither touches `objcell_id` or `cell`.** The frame-only claim holds. Per-commit ordering inside `SetPositionInternal`, worth having explicit: cell propagation (branch A loop, or branch B `change_cell` → recursive `enter_cell`) **first** `@0x00515372`/`@0x00515385`, then `set_frame` `@0x005153e0` → `UpdateChildrenInternal @0x00514108` → per-child `UpdateChild` → `set_frame(child)`. Cell before frame, every commit. --- ## 4. What happens on `unset_parent`? **The child gets no cell back. `unset_parent` performs zero cell work.** `CPhysicsObj::unset_parent @0x00513470`, complete body: ``` 00513473 parent = this->parent; if (parent == 0) return @0x00513478 00513484 CHILDLIST::remove_child(parent->children, this) 00513495 if (this->parent->state[1] & 0x40) { 00513497 this->state &= 0xffffffdf 005134a7 CPartArray::SetNoDrawInternal(part_array, 0) } 005134ac this->parent = nullptr 005134bf this->update_time = Timer::cur_time 005134ce return CPhysicsObj::clear_transient_states(this) ``` No `cell`, no `objcell_id`, no `leave_cell`. `CPhysicsObj::clear_transient_states @0x00511bf0` was read in full — it touches only `transient_state`, `calc_acceleration @0x00511bfa`/`@0x00511c2b`, and `MovementManager::LeaveGround @0x00511c24`. **No cell write.** Consequently, immediately after `unset_parent` the child still holds the parent's `objcell_id` **and** is still in that `CObjCell`'s object list. Every call site resolves this itself, in one of two ways: | Call site | Address | Resolution | |---|---|---| | `CPhysicsObj::set_parent` (both overloads) | `@0x00515aba`, `@0x00515b7e` | followed by `leave_world @0x00515ac1`/`@0x00515b85`, then `change_cell` to the new parent's cell | | `SmartBox::DoPickupEvent @0x00452240` | `@0x0045227f` | followed by `leave_world @0x00452286` — leaves the world | | `SmartBox::HandleReceivedPosition @0x00453fd0` | `@0x00454129` | followed by `SetPlacementFrame @0x00454142` and `MoveOrTeleport @0x00454254` — server-driven re-placement assigns a cell | | `CObjectMaint::DeleteObject @0x00508460` | `@0x005084b2` | `exit_world @0x0050846b` + `leave_world @0x00508472` ran **before**; then `unparent_children @0x005084b9`, then destructor | | `CObjectMaint::DestroyObjects @0x00508c30` | `@0x00508dd7` | same pattern, then `unparent_children @0x00508dde`, destructor | | `ParticleEmitter::Destroy @0x0051cdb0` | `@0x0051cdbd` | followed by `leave_world @0x0051cdc5` | `CPhysicsObj::leave_world @0x005155a0` is where a detaching child is actually scrubbed: `remove_shadows_from_cells @0x005155dd`, `leave_cell(this, 0) @0x005155e6` (which recurses children `@0x00510f84`), then `this->m_position.objcell_id = 0 @0x005155f4` and `CPartArray::SetCellID(..., 0) @0x00515606`. **One precise gap, stated as such:** `leave_world` zeroes only **its own** `objcell_id` `@0x005155f4`. Its recursive `leave_cell` clears each descendant's `cell` pointer `@0x00510fa7` but leaves each descendant's `objcell_id` at its stale value. So a grandchild of a world-leaving object retains a stale `objcell_id` with a null `cell` until the next `enter_cell`. This is retail's actual behavior, not an artifact of the decomp — the write at `@0x005155f4` is plainly on `this` and the recursion at `@0x00510f84` plainly does not carry an id-clearing write. --- ## 5. Does retail have anything resembling acdream's per-render-tick child rebucket? **Retail has a per-commit child cell re-stamp, but it is on the physics path, not a render path, and it is unconditional.** - **Name:** the child loop in `CPhysicsObj::SetPositionInternal(CPhysicsObj*, CTransition const*) @0x00515330`, at `@0x0051539c`–`@0x005153d8`. - **Cadence:** once per position commit, driven by `CPhysics::UseTime @0x00509950` → `update_object @0x005099e5` → `UpdateObjectInternal @0x005156b0` → `SetPositionInternal @0x00515914`. Same tick that runs collision and movement — it is not tied to drawing, to pose composition, or to mesh/part availability. - **The comparable acdream code** is `src/AcDream.App/Rendering/EquippedChildRenderController.cs:373` `TickChild`, whose `_liveEntities.RebucketLiveEntity(child.ChildGuid, parentCellId)` at line 407 writes the same **value** retail writes (child cell := parent cell), but reaches it only after `TryResolveExactAttachment`, `TryGetRootPose`, `TryGetPartPoseSnapshot`, and `EquippedChildAttachment.TryComposePoseInto` all succeed, and only after `ApplyParentWorldPose` returns true. Retail's equivalent write has no such predicates. So retail's cell propagation is **gated on nothing except `part_array != 0`** (Mechanism A) or **nothing at all** (Mechanism B), whereas acdream's is gated on successful render-side pose composition. That gating is the divergence, and it is exactly why headless — which never constructs `EquippedChildRenderController` — leaves parented children cell-less forever. --- ## Direct consequence for route 7 The child cell write **belongs in Runtime**, and specifically it belongs in *both* the `set_parent` analog *and* the position-commit analog — retail writes it in both places, and `set_parent` is the lesser of the two. Three consequences follow directly from the citations above: 1. **`set_parent` alone is insufficient.** Retail's `set_parent` performs no cell write of its own; it delegates one-shot placement to `change_cell @0x00515ad6`. The *sustaining* write — the one that keeps a child correct as its parent walks around — is `SetPositionInternal`'s child loop `@0x005153bd` and the recursive `enter_cell` `@0x00510f1e`, both on the physics commit. A Runtime `set_parent` analog that writes the cell once at attach time will be correct at attach and stale on the first parent cell crossing thereafter. 2. **The write is unconditional in retail and must be unconditional in Runtime.** Because `update_object @0x00515d10` early-returns on `parent != 0` `@0x00515d40`, a parented child has no tick of its own; the parent's commit is its only source of truth. Predicating the write on pose/mesh readiness — as `TickChild` does — has no retail counterpart. 3. **App's render-tick rebucket can be safely demoted**, because it writes a value Runtime already has at commit time (the parent's committed `objcell_id`) and needs none of the pose data it currently waits for. Its sole retail-justified residue is presentation (`child.Entity.ParentCellId`/`PublishChildPose`), not membership. **One-line verdict:** route 7 can safely demote App's render-tick rebucket to presentation-only, provided the Runtime replacement writes the child's cell at *both* the `set_parent` analog and the per-commit position analog — retail does both, and the per-commit one is the load-bearing half. --- ## NOT ESTABLISHED Nothing in the five questions is unresolved. Two adjacent facts are deliberately not claimed: - **When branch A of `SetPositionInternal` can observe `curr_pos.objcell_id != this->cell->m_DID.id`** (i.e. when the same `CObjCell` legitimately carries a changing `objcell_id`) is not established. It does not affect the answer — retail writes the child's id there unconditionally regardless — but if it matters later, the settling read is `CTransition`/`CSphere` `curr_cell` maintenance in `SPHEREPATH::set_curr_cell` plus outdoor `CObjCell` id assignment, or a cdb breakpoint on `CPhysicsObj::SetPositionInternal @0x00515330` logging `@ecx->cell->m_DID.id` against `arg2->sphere_path.curr_pos.objcell_id`. - **Whether any parented child in practice has children of its own** (the case where branch A's depth-1 loop would leave a grandchild stale) is not established from the decomp. Settling it is a live-data question, not a code question: a cdb breakpoint on `CPhysicsObj::set_parent @0x00515a90` logging `@ecx->children` non-null at attach time would answer it.