research: settle retail child-cell ownership (unblocks C4 route 7)

Route 7 wants to demote App's render-tick child rebucket
(EquippedChildRenderController.TickChild) into Runtime. Whether that is correct
depended entirely on whether retail propagates a parent's cell change to its
children. It does — via two independent mechanisms, both on the physics path.

Recursive: change_cell @0x00513390 -> enter_cell @0x00510ed0 recurses into
children @0x00510f03 passing the same CObjCell* down, each frame writing
m_position.objcell_id @0x00510f1e and cell @0x00510f35 and registering the
child individually via add_object @0x00510ee2. leave_cell @0x00510f50 mirrors
it @0x00510f84.

Per-commit: SetPositionInternal(CTransition const*) @0x00515330 has an explicit
depth-1 child loop @0x0051539c-@0x005153d8 writing child+0x4c from the parent's
curr_pos.objcell_id @0x005153bd.

The structural reason it must exist: update_object @0x00515d10 early-returns on
parent != 0 @0x00515d40, so a parented child is NEVER independently simulated.
The parent's tick is its only source of cell and frame.

This corrects the routes-6-7 scoping doc, which said retail re-cells "inside
set_parent". It does not — set_parent @0x00515a90 (both overloads read in full)
contains no cell write and delegates to change_cell @0x00515ad6.
recalc_cross_cells @0x00515a30 only READS objcell_id as a guard @0x00515a3f.
The prior UpdateChild finding is CONFIRMED: @0x00512d50 -> set_frame @0x00514090
writes m_position.frame @0x005140e9 only.

Consequence pinned for route 7's implementer: the Runtime replacement must
write the child's cell at BOTH the set_parent analog AND the per-commit
position analog. A set_parent-only write is correct at attach and stale on the
parent's first cell crossing — which is the natural misreading of the earlier
scoping.

Also recorded: unset_parent @0x00513470 does zero cell work (full body read),
leaving the child with a stale objcell_id still in that cell's object list;
all six call sites resolve it externally via leave_world @0x005155a0 (which
zeroes objcell_id @0x005155f4) or an explicit re-placement. +0x4c verified as
m_position.objcell_id by closing the offset chain against acclient.h rather
than trusting a Binary Ninja identifier. set_cell_id_recursive @0x00510da0 is a
red herring — its only caller is sky-object handling @0x00506eba.

Two adjacent facts remain NOT ESTABLISHED with the cdb breakpoints that would
settle them; neither affects the verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-04 04:34:01 +02:00
parent 11a8742884
commit 1b631f127d

View file

@ -0,0 +1,381 @@
# 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<unsigned long>` (`acclient.h:30149`), `HashBaseData<unsigned long>
= { 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<CShadowObj>`(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.