acdream/docs/research/2026-08-04-retail-child-cell-ownership.md
Erik 1b631f127d 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>
2026-08-04 04:34:01 +02:00

381 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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