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

18 KiB
Raw Permalink Blame History

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_cellenter_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 @0x00510ed0recurses 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 @0x00509950CPhysicsObj::update_object @0x005099e5 (@0x00515d10) → CPhysicsObj::UpdateObjectInternal @0x005156b0SetPositionInternal(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 @0x005153e0UpdateChildrenInternal @0x00514108 → per-child UpdateChildset_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 @0x00509950update_object @0x005099e5UpdateObjectInternal @0x005156b0SetPositionInternal @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.